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")) + } +}