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.
This commit is contained in:
Mihaela M 2026-06-28 03:21:04 +02:00
parent 4941eac711
commit 3291a3701f
4 changed files with 50 additions and 6 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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"))])))
}
}

View File

@ -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")