From 3291a3701fe01b96633576ee67f53341c002a5e5 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sun, 28 Jun 2026 03:21:04 +0200 Subject: [PATCH] fix(parsing): treat a dangling anchor or tag as an empty node A node property always attaches to a node, and an empty node is allowed, so an anchor or tag with nothing after it (a bare '&anchor', a 'key: &a', or a '!!str'/'!!null' with no value) denotes that property applied to a null node. parseNode rejected it as a missing node. parseNode now emits a null scalar carrying the properties when a node position holds only an anchor or tag, via a small helper to stay within the complexity limit; with no properties, a missing node is still an error. A '!!str' empty node resolves to the empty string and '!!null' to null. The error-path test that asserted a dangling property throws is corrected, as the construct is valid per the grammar and the official test-suite. Measured against the official YAML test-suite, parse-level conformance rises from 287/333 (86.2%) to 288/333 (86.5%), with value-level up one and no regression. --- CHANGELOG.md | 5 +++++ Sources/Parsing/TokenEventParser.swift | 17 +++++++++++++++-- Tests/EmptyTaggedNodeTests.swift | 21 +++++++++++++++++++++ Tests/ParsingErrorPathTests.swift | 13 +++++++++---- 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 Tests/EmptyTaggedNodeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 81efa4b..6964457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An anchor or tag with no node after it now denotes an empty node. A property always attaches to a + node and an empty node is allowed, so `&anchor` alone, `key: &a`, or a `!!str` with nothing after + it (`x: !!str` is the empty string, `x: !!null` is null) is the property applied to a null node + rather than a missing-node error. A real tagged or anchored value is unaffected. Lifts official + test-suite parse-level conformance from 287/333 (86.2%) to 288/333 (86.5%) and value-level by one. - An implicit null mapping key is now parsed. A `:` that opens a line with no key before it (`: value`, a bare `:`, or a null key among other entries) is an entry with a null key, which the parser previously rejected as a missing node. The scanner synthesizes the empty key, tracking diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index 47c2cad..833bb5f 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -207,6 +207,20 @@ extension PureYAML.Parsing.TokenEventParser { throw PureYAML.Parsing.ParseError.nestingTooDeep(line: mark.line, column: mark.column) } + /// Handles a node position with no node token. An anchor or tag with nothing after it + /// (`- !!str`, `&a` alone) applies to an empty node, so emit a null scalar carrying the + /// properties; with no properties, a missing node is an error. + mutating func emitEmptyNodeOrThrow( + properties: PureYAML.Parsing.NodeProperties, + ) throws -> PureYAML.Parsing.NodeResult { + let mark = properties.mark ?? cursor.current?.mark ?? cursor.previous?.endMark ?? .start + guard properties.anchor != nil || properties.tag != nil else { + throw PureYAML.Parsing.ParseError.expectedNode(line: mark.line, column: mark.column) + } + try emit(.scalar(value: "", anchor: properties.anchor, tag: properties.tag, style: .plain, mark: mark)) + return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark) + } + /// An alias is a bare reference, so it cannot carry an anchor or tag of its own (`&b *a`). mutating func parseAlias( anchor: String, @@ -232,8 +246,7 @@ extension PureYAML.Parsing.TokenEventParser { try properties.mergeUnset(from: consumeProperties()) guard let token = cursor.current, !token.kind.isTerminator else { - let mark = properties.mark ?? cursor.current?.mark ?? cursor.previous?.endMark ?? .start - throw PureYAML.Parsing.ParseError.expectedNode(line: mark.line, column: mark.column) + return try emitEmptyNodeOrThrow(properties: properties) } switch token.kind { diff --git a/Tests/EmptyTaggedNodeTests.swift b/Tests/EmptyTaggedNodeTests.swift new file mode 100644 index 0000000..bc56e46 --- /dev/null +++ b/Tests/EmptyTaggedNodeTests.swift @@ -0,0 +1,21 @@ +@testable import PureYAML +import Testing + +@Suite("Empty tagged and anchored nodes") +struct EmptyTaggedNodeTests { + @Test("A tag with no following node tags an empty node") + func test_emptyTaggedNode() throws { + #expect(try PureYAML.parse("x: !!str\n") == .mapping(.init([.init(key: "x", value: .string(""))]))) + #expect(try PureYAML.parse("x: !!null\n") == .mapping(.init([.init(key: "x", value: .null)]))) + } + + @Test("A tagged empty node works as the final sequence item") + func test_emptyTaggedSequenceItem() throws { + #expect(try PureYAML.parse("- a\n- !!str\n") == .sequence(.init([.string("a"), .string("")]))) + } + + @Test("A real tagged value is unaffected") + func test_taggedValueUnaffected() throws { + #expect(try PureYAML.parse("x: !!str hello\n") == .mapping(.init([.init(key: "x", value: .string("hello"))]))) + } +} diff --git a/Tests/ParsingErrorPathTests.swift b/Tests/ParsingErrorPathTests.swift index b500033..00c7368 100644 --- a/Tests/ParsingErrorPathTests.swift +++ b/Tests/ParsingErrorPathTests.swift @@ -3,10 +3,15 @@ import Testing @Suite("Parsing Error Paths") struct ParsingErrorPathTests { - @Test("A node property with no following node throws expectedNode") - func test_danglingNodePropertyThrowsExpectedNode() { - expectParseError("&anchor\n", .expectedNode(line: 1, column: 1)) - expectParseError("root:\n key: &a\nother: 1\n", .expectedNode(line: 2, column: 8)) + @Test("A node property with no following node is an empty (null) node") + func test_danglingNodePropertyIsEmptyNode() throws { + // A property always attaches to a node, and an empty node is allowed, so a dangling anchor + // or tag denotes a null node carrying that property rather than being an error. + #expect(try PureYAML.parse("&anchor\n") == .null) + #expect(try PureYAML.parse("root:\n key: &a\nother: 1\n") == .mapping(.init([ + .init(key: "root", value: .mapping(.init([.init(key: "key", value: .null)]))), + .init(key: "other", value: .int(1)), + ]))) } @Test("An unterminated flow mapping throws at stream end")