From e3caa5be0d3efdd1ad8a59e53335e62501b35676 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Mon, 29 Jun 2026 02:56:17 +0200 Subject: [PATCH] fix(parsing): resolve an alias to an anchor on a null value An anchor on an empty mapping value (`a: &anchor`) failed to register, so a later `*anchor` errored as an undefined alias. Two bugs compounded: - The parser dispatched the anchor-only value position to a nested block mapping, absorbing the sibling key as the anchored node's child instead of emitting the empty anchored node. It now emits an empty anchored node when only a sibling mapping key follows, since the indented and inline-nested cases are already handled by earlier branches. - The stream composer registered an anchor by its raw scalar text, so an anchor on an empty scalar came back as "" and a typed scalar (`&a 42`) as text. It now registers the composed value, fixing a latent typed-anchor bug. Fixes yaml-test-suite 6KGN; raises parse-level conformance to 306/333 (91.9%) and value-level to 199/199 (100.0%), with the should-error rate unchanged. --- CHANGELOG.md | 11 ++++++++ Sources/Parsing/EventStreamComposer.swift | 4 ++- .../Parsing/TokenEventParser.Helpers.swift | 7 +++++ Tests/AnchorOnNullValueTests.swift | 26 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Tests/AnchorOnNullValueTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c87fd02..a91d873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An anchor on an empty mapping value (`a: &anchor`) now registers that value's implicit null node, + so a later alias resolves to null instead of failing as an undefined alias. Two bugs compounded: + the parser dispatched the anchor-only value position to a nested block mapping, absorbing the + sibling key (`b:`) as the anchored node's child instead of emitting the empty anchored node; and + the stream composer registered an anchor by its raw scalar text rather than the resolved node, so + even a registered anchor on an empty scalar came back as `""` and a typed scalar (`&a 42`) came + back as text. The value position now emits an empty anchored node when only a sibling mapping key + follows, and an anchor registers the composed value. Fixes yaml-test-suite 6KGN and a latent + typed-scalar anchor bug; raises parse-level conformance to 306/333 (91.9%) and value-level to + 199/199 (100.0%), with the should-error rate unchanged. + - A `%YAML` directive with a higher minor version than 1.2 is now accepted rather than rejected. The spec calls for processing a document with a higher minor version, with a warning, so `%YAML 1.3` is read as a YAML 1.x document instead of erroring. The version check now accepts any well-formed diff --git a/Sources/Parsing/EventStreamComposer.swift b/Sources/Parsing/EventStreamComposer.swift index 6c5636b..314cdf5 100644 --- a/Sources/Parsing/EventStreamComposer.swift +++ b/Sources/Parsing/EventStreamComposer.swift @@ -92,7 +92,9 @@ private extension PureYAML.Parsing.EventStreamComposer { ) throws { let model = try scalarParser.composeScalarValue(value, tag: tag, style: style, mark: mark) if let anchor { - anchors[anchor] = .string(value) + // Register the resolved node, not the raw text: an alias resolves to the anchored node's + // value, so an empty scalar anchors null and a typed scalar (`&a 42`) anchors that value. + anchors[anchor] = model } try complete(.init( value: model, diff --git a/Sources/Parsing/TokenEventParser.Helpers.swift b/Sources/Parsing/TokenEventParser.Helpers.swift index 3a18b66..bbae835 100644 --- a/Sources/Parsing/TokenEventParser.Helpers.swift +++ b/Sources/Parsing/TokenEventParser.Helpers.swift @@ -33,6 +33,13 @@ extension PureYAML.Parsing.TokenEventParser { minimumColumn: cursor.current?.mark.column, ) } + // A value position holding only properties (`a: &anchor`) followed by a sibling mapping key + // has an empty (null) node carrying those properties, not a nested mapping: the indented + // nested case is consumed above by the indent branch, and an inline nested mapping is caught + // by the mapping-key-with-same-line-node branch, so a bare mapping key here is a sibling. + if properties != .none, cursor.current?.kind.isMappingKey == true { + return try emitEmptyNodeOrThrow(properties: properties) + } if properties != .none || hasNodeOnSameLine(after: valueIndicator) { return try parseNode(properties: properties) } diff --git a/Tests/AnchorOnNullValueTests.swift b/Tests/AnchorOnNullValueTests.swift new file mode 100644 index 0000000..3d28da6 --- /dev/null +++ b/Tests/AnchorOnNullValueTests.swift @@ -0,0 +1,26 @@ +@testable import PureYAML +import Testing + +@Suite("Anchor on a null or typed value resolves through its alias") +struct AnchorOnNullValueTests { + /// An anchor on a `key:` with no value anchors that key's implicit null node, so a later alias + /// resolves to null rather than failing as undefined (yaml-test-suite 6KGN). The empty value is + /// followed by a sibling mapping key, which must not be absorbed as a nested mapping. + @Test("An anchor on an empty mapping value registers a null node for its alias") + func test_anchorOnEmptyValueResolvesToNull() throws { + let value = try PureYAML.parse("a: &anchor\nb: *anchor\n") + let mapping = try requireMapping(value) + #expect(mapping?["a"] == .null) + #expect(mapping?["b"] == .null) + } + + /// The anchored node is resolved before registration, so a typed scalar anchors its typed value + /// (here an integer), not its raw text: the alias must come back as the same `.int(42)`. + @Test("An anchor on a typed scalar registers the resolved value, not its raw text") + func test_anchorOnTypedScalarResolvesToTypedValue() throws { + let value = try PureYAML.parse("a: &a 42\nb: *a\n") + let mapping = try requireMapping(value) + #expect(mapping?["a"] == .int(42)) + #expect(mapping?["b"] == .int(42)) + } +}