diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4e57c..ecde8cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Tagged/Tagged.Dumper.Canonical.swift b/Sources/Tagged/Tagged.Dumper.Canonical.swift index d0ec523..ae2bd1b 100644 --- a/Sources/Tagged/Tagged.Dumper.Canonical.swift +++ b/Sources/Tagged/Tagged.Dumper.Canonical.swift @@ -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] += "," diff --git a/Sources/Tagged/Tagged.Dumper.swift b/Sources/Tagged/Tagged.Dumper.swift index a5c58f0..7353196 100644 --- a/Sources/Tagged/Tagged.Dumper.swift +++ b/Sources/Tagged/Tagged.Dumper.swift @@ -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: ", ") + "}" } diff --git a/Sources/Tagged/Tagged.EventComposer.swift b/Sources/Tagged/Tagged.EventComposer.swift index b90c8f1..cbaca99 100644 --- a/Sources/Tagged/Tagged.EventComposer.swift +++ b/Sources/Tagged/Tagged.EventComposer.swift @@ -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 diff --git a/Sources/Tagged/Tagged.Pair.swift b/Sources/Tagged/Tagged.Pair.swift index 8133a6f..0a9d683 100644 --- a/Sources/Tagged/Tagged.Pair.swift +++ b/Sources/Tagged/Tagged.Pair.swift @@ -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 } } } diff --git a/Tests/TaggedNodeCorpusRoundTripTests.swift b/Tests/TaggedNodeCorpusRoundTripTests.swift index ed1e190..9f94e8e 100644 --- a/Tests/TaggedNodeCorpusRoundTripTests.swift +++ b/Tests/TaggedNodeCorpusRoundTripTests.swift @@ -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 diff --git a/Tests/TaggedNodeRoundTripTests.swift b/Tests/TaggedNodeRoundTripTests.swift index b6e212d..77a7f9d 100644 --- a/Tests/TaggedNodeRoundTripTests.swift +++ b/Tests/TaggedNodeRoundTripTests.swift @@ -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) + } +}