From c8583393073576bcca19e9c53f4e4120de9e31c6 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Mon, 29 Jun 2026 03:24:30 +0200 Subject: [PATCH] fix(parsing): bind an inline first-key property to the key A property written inline before a block mapping's first key (`&a key: v`) was attached to the whole mapping, so an anchor registered only when the mapping ended, too late for an alias inside one of the mapping's values to resolve. The YAML grammar requires a block collection's own properties to be followed by a line break (`s-l+block-collection`), so a property on the first key's line belongs to the key, not the collection. The mapping now keeps only a property written on its own line above the first key; an inline one is threaded back to the first pair, where the key parses it and an anchor registers with the key. Fixes yaml-test-suite 2SXE (`&a:` and `&a` are distinct anchors, since a name includes a trailing `:`) and HMQ5 (a key carrying both a tag and an anchor). Raises parse-level conformance to 308/333 (92.5%) and value-level to 201/201 (100.0%), with the should-error rate unchanged. --- CHANGELOG.md | 10 ++++++ Sources/Parsing/TokenEventParser.Block.swift | 27 +++++++++++++-- .../Parsing/TokenEventParser.Helpers.swift | 13 ++++--- Tests/InlineKeyPropertyAnchorTests.swift | 34 +++++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 Tests/InlineKeyPropertyAnchorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a91d873..59b92b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A property written inline before a block mapping's first key (`&a key: value`) now binds to that + key rather than to the whole mapping. The YAML grammar requires a block collection's own + properties to be followed by a line break, so a property on the first key's line belongs to the + key, not the collection. The parser had attached it to the mapping, where an anchor registered + only at the mapping's end, too late for an alias inside one of the mapping's values to resolve. A + property on its own line above the key still binds to the mapping. Fixes yaml-test-suite 2SXE + (anchor names include a trailing `:`, so `&a:` and `&a` are distinct anchors, already handled by + the scanner) and HMQ5 (a key carrying both a tag and an anchor); raises parse-level conformance to + 308/333 (92.5%) and value-level to 201/201 (100.0%), with the should-error rate unchanged. + - 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 diff --git a/Sources/Parsing/TokenEventParser.Block.swift b/Sources/Parsing/TokenEventParser.Block.swift index 8b512f3..f1b97dd 100644 --- a/Sources/Parsing/TokenEventParser.Block.swift +++ b/Sources/Parsing/TokenEventParser.Block.swift @@ -125,9 +125,24 @@ extension PureYAML.Parsing.TokenEventParser { let contentColumn = min(startMark.column, tokenColumn) let boundaryColumn = minimumColumn ?? contentColumn var endMark = startMark + // A property written inline before the first key (`&a key: v`) binds to that key, not to the + // mapping: the spec requires a block collection's own properties to be followed by a line + // break (`s-l+block-collection`), so a property on the key's line is the key's. This matters + // for anchors, which must register with the key (early enough for an alias inside a value to + // resolve) rather than with the mapping, which registers only at its end. A property on its + // own line above the first key still binds to the mapping. + let firstKeyProperties: PureYAML.Parsing.NodeProperties + let mappingProperties: PureYAML.Parsing.NodeProperties + if properties != .none, properties.mark?.line == cursor.current?.mark.line { + firstKeyProperties = properties + mappingProperties = .none + } else { + firstKeyProperties = .none + mappingProperties = properties + } try emit(.mappingStart( - anchor: properties.anchor, - tag: properties.tag, + anchor: mappingProperties.anchor, + tag: mappingProperties.tag, style: .block, mark: startMark, )) @@ -135,6 +150,7 @@ extension PureYAML.Parsing.TokenEventParser { try parseBlockMappingPairs( boundaryColumn: boundaryColumn, includeIndentedContinuation: includeIndentedContinuation, + firstKeyProperties: firstKeyProperties, endMark: &endMark, ) if let current = cursor.current, !canEndBlockMapping(at: current, minimumColumn: boundaryColumn) { @@ -152,13 +168,17 @@ extension PureYAML.Parsing.TokenEventParser { mutating func parseBlockMappingPairs( boundaryColumn: Int, includeIndentedContinuation: Bool, + firstKeyProperties: PureYAML.Parsing.NodeProperties = .none, endMark: inout PureYAML.Parsing.Mark, ) throws { + var pendingKeyProperties = firstKeyProperties while canContinueBlockMapping(minimumColumn: boundaryColumn) { endMark = try parseMappingPairAndBoundaries( boundaryColumn: boundaryColumn, includeIndentedContinuation: includeIndentedContinuation, + firstKeyProperties: pendingKeyProperties, ).endMark + pendingKeyProperties = .none } while includeIndentedContinuation, cursor.current?.kind.isIndent == true { endMark = try parseIndentedBlockMappingPairs(boundaryColumn: boundaryColumn).endMark @@ -188,8 +208,9 @@ extension PureYAML.Parsing.TokenEventParser { mutating func parseMappingPairAndBoundaries( boundaryColumn: Int, includeIndentedContinuation: Bool, + firstKeyProperties: PureYAML.Parsing.NodeProperties = .none, ) throws -> PureYAML.Parsing.NodeResult { - let result = try parseMappingPair() + let result = try parseMappingPair(firstKeyProperties: firstKeyProperties) try consumeMappingSiblingBoundaryDedents(minimumColumn: boundaryColumn) try consumeMappingSiblingBoundaryReindent(minimumColumn: boundaryColumn) if includeIndentedContinuation { diff --git a/Sources/Parsing/TokenEventParser.Helpers.swift b/Sources/Parsing/TokenEventParser.Helpers.swift index bbae835..61fb680 100644 --- a/Sources/Parsing/TokenEventParser.Helpers.swift +++ b/Sources/Parsing/TokenEventParser.Helpers.swift @@ -1,9 +1,12 @@ extension PureYAML.Parsing.TokenEventParser { - mutating func parseMappingPair() throws -> PureYAML.Parsing.NodeResult { - // A subsequent pair's key may carry an anchor or tag (`&a key: …`). The first pair's - // properties are consumed when the mapping is entered; consuming here covers the rest, and - // is a no-op (`.none`) when there are none. - let keyProperties = try consumeProperties() + mutating func parseMappingPair( + firstKeyProperties: PureYAML.Parsing.NodeProperties = .none, + ) throws -> PureYAML.Parsing.NodeResult { + // A subsequent pair's key may carry an anchor or tag (`&a key: …`). The first pair's inline + // properties are consumed when the mapping is entered and passed back in here; consuming + // again covers any on a later key, and is a no-op (`.none`) when there are none. + var keyProperties = firstKeyProperties + try keyProperties.mergeUnset(from: consumeProperties()) _ = try expect("mapping key") { $0.isMappingKey } let key = try parseNode(properties: keyProperties) // An explicit key (`? key`) with no following `:` has an implicit null value, so several diff --git a/Tests/InlineKeyPropertyAnchorTests.swift b/Tests/InlineKeyPropertyAnchorTests.swift new file mode 100644 index 0000000..8e61df2 --- /dev/null +++ b/Tests/InlineKeyPropertyAnchorTests.swift @@ -0,0 +1,34 @@ +@testable import PureYAML +import Testing + +@Suite("An inline property before the first key binds to the key, not the mapping") +struct InlineKeyPropertyAnchorTests { + /// A property written inline before the first key (`&k key: v`) anchors that key, so the anchor + /// registers with the key and resolves early, before the enclosing mapping ends. Binding it to + /// the mapping would register only at the mapping's end, too late for an alias inside a value. + @Test("An inline anchor on the first key resolves from a later value") + func test_inlineFirstKeyAnchorResolves() throws { + let mapping = try requireMapping(PureYAML.parse("&k key: v\nx: *k\n")) + #expect(mapping?["key"] == .string("v")) + #expect(mapping?["x"] == .string("key")) + } + + /// yaml-test-suite 2SXE: anchor names include `:` (a valid `ns-anchor-char`), so `&a:` and `&a` + /// are distinct anchors. The alias `*a:` resolves to the `&a:`-anchored key `key`, not the + /// `&a`-anchored value, and the inline anchor binds to the key rather than the whole mapping. + @Test("Distinct anchor names differing only by a trailing colon resolve independently") + func test_anchorNamesIncludingColon() throws { + let mapping = try requireMapping(PureYAML.parse("&a: key: &a value\nfoo:\n *a:\n")) + #expect(mapping?["key"] == .string("value")) + #expect(mapping?["foo"] == .string("key")) + } + + /// yaml-test-suite HMQ5: a key may carry both a tag and an anchor (`!!str &a1 "foo"`); the anchor + /// registers the resolved key value so a later `*a1` comes back as that value. + @Test("A first key carrying both a tag and an anchor registers for its alias") + func test_inlineFirstKeyTagAndAnchor() throws { + let mapping = try requireMapping(PureYAML.parse("!!str &a1 \"foo\":\n !!str bar\n&a2 baz : *a1\n")) + #expect(mapping?["foo"] == .string("bar")) + #expect(mapping?["baz"] == .string("foo")) + } +}