fix(parsing): parse a property-only block mapping key as an empty key

A block key that is only a node property (`&a : v`, `!!str : v`) parsed to an
`expectedNode` error. The scanner synthesized an empty mapping key for a bare
`: v` line but not when the line's sole content before the `:` was an anchor or
tag, so no key marker was emitted and the parser rejected the `:`.

Track non-property content per line (a leading `&`/`!` does not count, and a
block entry opens a fresh node position) and synthesize the empty key whenever
only properties precede the `:`. The property tokens already bind to the
synthesized empty scalar through the existing mapping machinery, so no parser
change is needed. Relocate the synthesis helper to Scanner.Helpers to keep
Scanner under the file-length limit.

yaml-test-suite FH7J now passes (parse-level 313/333 to 314/333); PW8X still
needs the empty-anchored explicit-value form. should-error rejections and
value-level matches are unchanged, and a value-level regression test pins the
produced tree for each property-only-key shape.
This commit is contained in:
Mihaela M 2026-06-29 08:26:50 +02:00
parent 941c3f948a
commit 8d245ae335
5 changed files with 69 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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