diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a785fe..5df8a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A block mapping key consisting only of a node property (`&a : v`, `!!str : v`) is now parsed as an + empty implicit key carrying that property, the block analogue of the flow `{ !!str : v }` case. The + scanner synthesized an empty mapping key only for a bare `: v` line; a line whose sole content + before the `:` was an anchor or tag emitted no key marker, so the parser rejected the `:` with an + `expectedNode` error. The scanner now tracks non-property content per line and synthesizes the empty + key after a property, including directly after a block entry (`- !!str : v`). This makes + yaml-test-suite FH7J pass; PW8X still needs the empty-anchored explicit-value form (`: &a`). + Parse-level conformance rises from 313/333 to 314/333; should-error rejections and value-level + matches are unchanged. - A line beginning with `%` inside a block scalar is now kept as literal content rather than being misread as a directive. A zero-indent literal or folded scalar sits at column 0, where the directive scan runs, so a content line such as `%!PS-Adobe-2.0` was rejected as an unsupported diff --git a/Sources/Parsing/Scanner.Helpers.swift b/Sources/Parsing/Scanner.Helpers.swift index 39802d8..400bf1e 100644 --- a/Sources/Parsing/Scanner.Helpers.swift +++ b/Sources/Parsing/Scanner.Helpers.swift @@ -370,4 +370,24 @@ extension PureYAML.Parsing.Scanner { } return !state.sawMappingValueOnLine } + + /// A `:` with no node before it on the line is an implicit empty mapping key, unless it is the + /// value indicator of a pending explicit `?` key. This covers both a bare `: value` and a line + /// whose only prior content is a node property (`&a : value`, `!!str : value`): the property + /// decorates the empty key, which the preceding property tokens bind to. Synthesizes the empty + /// key and its node so the block mapping machinery treats the pair uniformly. + func synthesizeNullMappingKeyIfNeeded( + _ character: Character, + hadNonPropertyContentOnLine: Bool, + state: inout State, + ) { + guard character == ":", !hadNonPropertyContentOnLine, state.flowDepth == 0, + !state.pendingExplicitKey, isAtMappingValueIndicator(state: state) + else { + return + } + let mark = state.reader.mark + state.append(.mappingKey, mark: mark, endMark: mark) + state.append(.scalar(value: "", style: .plain), mark: mark, endMark: mark) + } } diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 8d8a79d..6f99456 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -12,6 +12,7 @@ extension PureYAML.Parsing { captureBlockScalarBlankLine(&state) state.isAtLineStart = true state.sawContentOnLine = false + state.sawNonPropertyContentOnLine = false state.sawMappingValueOnLine = false state.sawFlowCloseOnLine = false continue @@ -92,6 +93,11 @@ extension PureYAML.Parsing.Scanner { /// simple flag, not a token-line comparison, because a blank line inside a block scalar /// emits an empty scalar attributed to the next line, which would read as phantom content. var sawContentOnLine = false + /// Like ``sawContentOnLine`` but not set by a node property (an anchor or tag). A `:` whose + /// only preceding same-line content is a property (`&a :`, `!!str :`) is an empty implicit + /// key carrying that property, so a null mapping key is synthesized for it exactly as for a + /// bare `: value`. Reset at each line break. + var sawNonPropertyContentOnLine = false // Whether a block-level mapping-value indicator (`:`) has been emitted on the current line. // Once one has, a later scalar followed by `:` is not a new implicit key: block style allows // only one mapping pair per line, so the document would be a forbidden inline nested mapping @@ -166,7 +172,14 @@ extension PureYAML.Parsing.Scanner { ) } let hadContentOnLine = state.sawContentOnLine + let hadNonPropertyContentOnLine = state.sawNonPropertyContentOnLine state.sawContentOnLine = true + // A leading `&` or `!` begins a node property (anchor or tag), which is not itself a key or + // value; every other token character is real content. A property-only line followed by `:` + // is thus an empty implicit key (`&a :`), synthesized below like a bare `: value`. + if character != "&", character != "!" { + state.sawNonPropertyContentOnLine = true + } // An implicit-key mapping value (`:` preceded by its key on the same line) means the line // now holds a pair, so a later scalar followed by `:` cannot start another: that would be a // forbidden inline nested block mapping (`a: b: c`). A `:` that opens the line is an explicit- @@ -175,7 +188,7 @@ extension PureYAML.Parsing.Scanner { if character == ":", hadContentOnLine, state.flowDepth == 0, isAtMappingValueIndicator(state: state) { state.sawMappingValueOnLine = true } - synthesizeNullMappingKeyIfNeeded(character, hadContentOnLine: hadContentOnLine, state: &state) + synthesizeNullMappingKeyIfNeeded(character, hadNonPropertyContentOnLine: hadNonPropertyContentOnLine, state: &state) if try scanLineIndicatorToken(character, state: &state) { return } @@ -201,24 +214,6 @@ extension PureYAML.Parsing.Scanner { scanPlainScalar(&state) } - /// A `:` that opens a line with no key before it is an implicit null mapping key (`: value`), - /// unless it is the value indicator of a pending explicit `?` key. Synthesizes the empty key and - /// its node so the block mapping machinery treats the pair uniformly. - func synthesizeNullMappingKeyIfNeeded( - _ character: Character, - hadContentOnLine: Bool, - state: inout State, - ) { - guard character == ":", !hadContentOnLine, state.flowDepth == 0, !state.pendingExplicitKey, - isAtMappingValueIndicator(state: state) - else { - return - } - let mark = state.reader.mark - state.append(.mappingKey, mark: mark, endMark: mark) - state.append(.scalar(value: "", style: .plain), mark: mark, endMark: mark) - } - func scanLineIndicatorToken( _ character: Character, state: inout State, @@ -372,6 +367,9 @@ extension PureYAML.Parsing.Scanner { state.append(kind, mark: start, endMark: state.reader.mark) if case .blockEntry = kind { recordBlockEntryImplicitIndentation(start: start, state: &state) + // A block entry opens a fresh node position, so a property-only key after it on the same + // line (`- !!str : v`) is still an empty implicit key: the `-` is not content of the key. + state.sawNonPropertyContentOnLine = false } if case .mappingValue = kind, state.flowContexts.last?.kind == .mapping { state.flowContexts[state.flowContexts.count - 1].isExpectingKey = false diff --git a/Sources/Parsing/TokenCursor.swift b/Sources/Parsing/TokenCursor.swift index c6e2f7d..52956ce 100644 --- a/Sources/Parsing/TokenCursor.swift +++ b/Sources/Parsing/TokenCursor.swift @@ -92,6 +92,7 @@ extension PureYAML.Parsing { scanner.captureBlockScalarBlankLine(&scannerBox.state) scannerBox.state.isAtLineStart = true scannerBox.state.sawContentOnLine = false + scannerBox.state.sawNonPropertyContentOnLine = false scannerBox.state.sawMappingValueOnLine = false scannerBox.state.sawFlowCloseOnLine = false continue diff --git a/Tests/ImplicitNullKeyTests.swift b/Tests/ImplicitNullKeyTests.swift index e61c076..6a91c1e 100644 --- a/Tests/ImplicitNullKeyTests.swift +++ b/Tests/ImplicitNullKeyTests.swift @@ -20,6 +20,28 @@ struct ImplicitNullKeyTests { ]))) } + @Test("A property-only key before `:` is an empty key carrying that property") + func test_propertyOnlyKey() throws { + // An anchor or tag with no scalar before the `:` decorates an empty implicit key: the block + // analogue of the flow `{ !!str : v }` case. The resolved key is the empty scalar; the + // property binds to it during construction. + #expect(try PureYAML.parse("&a : b\n") == .mapping(.init([ + .init(keyNode: .string(""), value: .string("b")), + ]))) + #expect(try PureYAML.parse("!!str : a\n") == .mapping(.init([ + .init(keyNode: .string(""), value: .string("a")), + ]))) + } + + @Test("A property-only key after a block entry is an empty key") + func test_propertyOnlyKeyAfterBlockEntry() throws { + // The `-` opens a fresh node position, so `!!str :` after it is still an empty implicit key + // rather than a malformed node. + #expect(try PureYAML.parse("- !!str : a\n") == .sequence([ + .mapping(.init([.init(keyNode: .string(""), value: .string("a"))])), + ])) + } + @Test("Explicit keys, including a complex one, are not mistaken for null keys") func test_explicitKeysUnaffected() throws { #expect(try PureYAML.parse("? a\n: b\n") == .mapping(.init([