feat(tagged): carry mapping keys as full nodes

Closes #12 and closes #13. Tagged.Pair stores keyNode instead of a
bare string: alias mapping keys parse on the Tagged path (composer
resolves them through the anchors table, dumper re-emits the reference
with the mandatory space before the colon since a colon is a legal
anchor character), key anchors register for later aliases and re-emit,
and a key's presentation style survives dump and reparse. key and
keyTag remain as read-through computed accessors and the string-key
convenience init keeps most call sites source compatible. A style-less
hand-built key keeps the plain-when-safe emission convention. The
corpus round-trip law compares key nodes structurally; alias-key
documents move from rejected to round-tripping.
This commit is contained in:
Mihaela Mihaljevic 2026-07-16 15:42:56 +02:00
parent 81e2794c9c
commit e51df57c35
7 changed files with 156 additions and 37 deletions

View File

@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `Tagged.Pair` now stores its key as a full node (`keyNode`), closing
issues #12 and #13: alias mapping keys (`*b : value`, yaml-test-suite
E76Z/26DV) parse on the `Tagged` path instead of throwing
`expectedScalarKey`, key anchors register and re-emit, and a key's
presentation style (a deliberately quoted `"on":`) survives dump and
reparse. `key: String` and `keyTag` remain as read-through computed
accessors (an alias key resolves to its anchored scalar's text), and
the `init(key:keyTag:value:)` convenience still builds plain string
keys, so most call sites compile unchanged. A style-less hand-built
key keeps the traditional plain-when-safe emission convention rather
than inheriting the document scalar policy. The corpus round-trip law
now compares key nodes structurally, and alias-key documents move
from the rejected column to round-tripping.
- Standard-tag typed accessors on `Tagged.Scalar` (issue #11):
`timestampValue` parses a `!!timestamp` scalar under the YAML 1.1
timestamp grammar into the new pure-Swift `Tagged.Timestamp` (calendar

View File

@ -63,17 +63,9 @@ extension PureYAML.Tagged.Dumper {
let innerPrefix = String(repeating: " ", count: inner)
var lines = [prefix + props + "{"]
for pair in mapping.pairs {
let keyTag = renderTag(pair.keyTag ?? .string)
var quotedKey = ""
for character in pair.key {
switch character {
case "\"", "\\":
quotedKey += "\\" + String(character)
default:
quotedKey.append(character)
}
}
lines.append(innerPrefix + "? " + keyTag + " \"" + quotedKey + "\"")
var keyLines = canonicalLines(pair.keyNode, indent: inner)
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
lines.append(contentsOf: keyLines)
var valueLines = canonicalLines(pair.value, indent: inner)
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
valueLines[valueLines.count - 1] += ","

View File

@ -198,10 +198,7 @@ extension PureYAML.Tagged.Dumper {
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
var key = renderer.escapeKey(pair.key)
if let keyTag = pair.keyTag {
key = renderTag(keyTag) + " " + key
}
let key = renderKey(pair.keyNode, renderer: renderer)
if let block = literalBlock(for: pair.value, contentIndent: indent + options.indentWidth) {
return [prefix + key + ": " + block.header] + block.content
}
@ -246,6 +243,35 @@ extension PureYAML.Tagged.Dumper {
}
}
/// A mapping key rendered from its node: an alias key emits `*name `
/// with a trailing space (`:` is a legal anchor character, so the colon
/// must not touch the alias name), a scalar key emits its properties
/// and its style-honoring single-line form (block styles on a key fall
/// back to quoting inside `renderScalarValue`).
func renderKey(
_ keyNode: Node,
renderer: PureYAML.Emitting.Dumper,
) -> String {
switch keyNode {
case let .alias(anchor, _):
return "*" + anchor + " "
case let .scalar(scalar):
let props = properties(keyNode)
// A style-less key keeps the traditional key convention (plain
// when safe, quoted otherwise) instead of inheriting the
// document's scalar policy, which would quote every key under
// the default options.
let rendered = scalar.style == nil
? renderer.escapeKey(scalar.rawValue)
: renderScalarValue(scalar, flow: false, renderer: renderer)
return props.isEmpty ? rendered : props + " " + rendered
case .mapping, .sequence:
// Unreachable from parsing (keys compose as scalars or aliases);
// a hand-built collection key renders as its resolved string.
return renderer.escapeKey("")
}
}
/// The block-scalar form for a scalar node whose style asks for one,
/// with the node's properties folded into the header; `nil` for
/// non-scalars and for scalars that render inline.
@ -311,7 +337,7 @@ extension PureYAML.Tagged.Dumper {
case let .mapping(mapping):
body = "{" + mapping.pairs
.map { pair in
renderer.escapeKey(pair.key) + ": " + renderFlow(pair.value, renderer: renderer)
renderKey(pair.keyNode, renderer: renderer) + ": " + renderFlow(pair.value, renderer: renderer)
}
.joined(separator: ", ") + "}"
}

View File

@ -169,9 +169,9 @@ extension PureYAML.Tagged.EventComposer {
}
var pairs: [PureYAML.Tagged.Pair] = []
while current?.isMappingEnd != true {
let key = try composeMappingKey()
let keyNode = try composeMappingKey()
let value = try composeNode()
pairs.append(.init(key: key.value, keyTag: key.tag, value: value))
pairs.append(.init(keyNode: keyNode, value: value))
}
_ = try expect("mapping end") { $0.isMappingEnd }
return .mapping(.init(
@ -182,10 +182,21 @@ extension PureYAML.Tagged.EventComposer {
))
}
mutating func composeMappingKey() throws -> ComposedMappingKey {
mutating func composeMappingKey() throws -> PureYAML.Tagged.Node {
guard let event = current else {
throw unexpectedEvent(expected: "mapping key")
}
if case let .alias(anchor, mark) = event {
index += 1
guard let resolved = anchors[anchor], case .scalar = resolved.resolved else {
throw PureYAML.Parsing.ParseError.undefinedAlias(
anchor: anchor,
line: mark.line,
column: mark.column,
)
}
return .alias(anchor: anchor, node: resolved)
}
guard case let .scalar(value, anchor, tag, style, mark, _) = event else {
let mark = event.mark
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
@ -199,16 +210,11 @@ extension PureYAML.Tagged.EventComposer {
style: .init(style),
)
store(.scalar(scalar), anchor: anchor)
return ComposedMappingKey(value: value, tag: .normalized(tag))
return .scalar(scalar)
}
}
extension PureYAML.Tagged.EventComposer {
struct ComposedMappingKey {
var value: String
var tag: PureYAML.Tagged.Tag?
}
var current: PureYAML.Parsing.Event? {
guard events.indices.contains(index) else {
return nil

View File

@ -1,17 +1,44 @@
public extension PureYAML.Tagged {
struct Pair: Equatable, Sendable {
public var key: String
public var keyTag: Tag?
/// The key as a full node, carrying its tag, anchor, presentation
/// style, and alias-ness, so anchored, styled, and alias keys
/// round-trip instead of collapsing to a bare string (issues #12
/// and #13).
public var keyNode: Node
public var value: Node
public init(
keyNode: Node,
value: Node,
) {
self.keyNode = keyNode
self.value = value
}
/// Convenience for the overwhelmingly common plain string key.
public init(
key: String,
keyTag: Tag? = nil,
value: Node,
) {
self.key = key
self.keyTag = keyTag
self.value = value
self.init(
keyNode: .scalar(.init(rawValue: key, value: .string(key), tag: keyTag)),
value: value,
)
}
/// The key's resolved raw text: an alias key reads through to its
/// anchored scalar, matching `parse(_:)`'s alias-key semantics.
public var key: String {
guard case let .scalar(scalar) = keyNode.resolved else {
return ""
}
return scalar.rawValue
}
/// The key's tag, read through an alias to its resolution.
public var keyTag: Tag? {
keyNode.resolved.tag
}
}
}

View File

@ -128,11 +128,8 @@ struct TaggedNodeCorpusRoundTripTests {
return "\(path): mapping properties differ (style \(String(describing: lhs.style)) != \(String(describing: rhs.style)))"
}
for (left, right) in zip(lhs.pairs, rhs.pairs) {
if left.key != right.key {
return "\(path): key \(left.key.debugDescription) != \(right.key.debugDescription)"
}
if left.keyTag != right.keyTag {
return "\(path).\(left.key): keyTag differs"
if let diff = firstDivergence(left.keyNode, right.keyNode, path: "\(path).key(\(left.key))") {
return diff
}
if let diff = firstDivergence(left.value, right.value, path: "\(path).\(left.key)") {
return diff

View File

@ -141,8 +141,14 @@ struct TaggedNodeRoundTripTests {
))
let tree = PureYAML.Tagged.Node.mapping(.init(
pairs: [
.init(key: "original", value: shared),
.init(key: "reference", value: .alias(anchor: "common", node: shared)),
.init(
keyNode: .scalar(.init(rawValue: "original", value: .string("original"), style: .plain)),
value: shared,
),
.init(
keyNode: .scalar(.init(rawValue: "reference", value: .string("reference"), style: .plain)),
value: .alias(anchor: "common", node: shared),
),
],
style: .block,
))
@ -205,3 +211,55 @@ struct TaggedNodeRoundTripTests {
#expect(result.values.count == 1)
}
}
/// Issue #12/#13 coverage: alias keys, anchored keys, and key presentation
/// styles carried on `Tagged.Pair.keyNode`.
@Suite("Tagged mapping key nodes round-trip")
struct TaggedMappingKeyNodeTests {
@Test("An alias mapping key parses and re-emits as a reference")
func test_aliasKeyRoundTrips() throws {
let source = "&a a: &b b\n*b : *a\n"
let parsed = try PureYAML.parseTagged(source)
guard case let .mapping(mapping) = parsed else {
Issue.record("expected mapping")
return
}
#expect(mapping.pairs[1].keyNode.kind == .alias)
#expect(mapping.pairs[1].key == "b")
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("*b :"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("The nested alias-key form from the test suite round-trips")
func test_nestedAliasKeyRoundTrips() throws {
let source = "top1: &x scalar1\ntop3:\n *x : scalar3\n"
let parsed = try PureYAML.parseTagged(source)
let reparsed = try PureYAML.parseTagged(PureYAML.dump(parsed))
#expect(reparsed == parsed)
#expect(try parsed.modelValueErasingTags == (PureYAML.parse(source)))
}
@Test("A quoted key keeps its quoting through dump and reparse")
func test_quotedKeyStyleRoundTrips() throws {
let source = "\"on\": true\nplain: false\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("\"on\":"))
#expect(dumped.contains("plain:"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("An anchored key's anchor survives and resolves for later aliases")
func test_anchoredKeyAnchorSurvives() throws {
let source = "&k name: 1\nref: *k\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("&k name:"))
#expect(dumped.contains("*k"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
}