PureYAML/Sources/Parsing/Scanner.FlowKey.swift
Mihaela M 5a0e6c732e fix(parsing): parse a flow collection before a colon as a mapping key
A flow collection followed by a same-line `: ` indicator is an implicit block
mapping key, e.g. `[a, b]: value` or `{a: 1}: value`, but the parser took the
flow node as the document's root value and then rejected the trailing `:` with
"expected document boundary".

The marker that tells the parser a node is a key is emitted before the node,
so for a flow key it must precede the flow tokens, which are already drained
by the time the `:` is seen (the streaming cursor drains after each token).
Resolve it in the scanner with a character lookahead from the flow opener to
its matching close, skipping quoted scalars so their brackets and colons do
not miscount the nesting; when a same-line `:` boundary follows, emit the same
implicit-key marker a scalar key gets, so the flow node parses as the key
through the ordinary mapping path with no parser change.

A block implicit key must lie on one line, so a flow collection spanning a
line break before its `:` (`[23 / ]: 42`) is rejected, which a first cut
wrongly accepted until the lookahead was made to stop at a line break. A flow
node that no `:` follows, or that is a mapping value, is unaffected.

Raises yaml-test-suite parse-level to 294/333 (88.3%) with the should-error
rejection rate unchanged. 416 tests pass.
2026-06-28 18:34:11 +02:00

90 lines
3.9 KiB
Swift

extension PureYAML.Parsing.Scanner {
/// Emits the implicit-key marker before a block-level flow collection that a `: ` follows, so the
/// flow node is parsed as a mapping key (e.g. `[a, b]: value`) through the same path a scalar key
/// takes. Skipped inside a flow context, after a mapping value already on the line (block style
/// allows one pair per line), and under a pending explicit `?` key, which marks its own key.
func appendFlowImplicitKeyMarkerIfNeeded(state: inout State) {
guard state.flowDepth == 0, !state.sawMappingValueOnLine, !state.pendingExplicitKey,
flowCollectionIsImplicitKey(state: state)
else {
return
}
let mark = state.reader.mark
state.append(.mappingKey, mark: mark, endMark: mark)
}
/// Looks ahead from a block-level flow collection opener (`[` or `{`) to decide whether the whole
/// collection is an implicit mapping key: a flow node immediately followed by a `: ` mapping-value
/// indicator, e.g. `[a, b]: value` or `{a: 1}: value`. Quoted scalars are skipped so brackets and
/// colons inside them do not affect the nesting count, and the `:` must sit on the same line as
/// the close (only spaces between), since a block implicit key carries its `:` on its own line.
/// Returns false if the brackets do not balance before end of input.
func flowCollectionIsImplicitKey(state: State) -> Bool {
var depth = 0
var offset = 0
while let character = state.reader.peek(offset: offset) {
// A block implicit key must lie on a single line, so a flow collection that spans a line
// break (outside any quoted scalar) before its `:` cannot be one: `[23\n]: 42` is an error.
if isLineBreak(character) {
return false
}
switch character {
case "'":
offset = skipSingleQuotedLookahead(after: offset, state: state)
case "\"":
offset = skipDoubleQuotedLookahead(after: offset, state: state)
case "[", "{":
depth += 1
offset += 1
case "]", "}":
depth -= 1
offset += 1
guard depth > 0 else {
while state.reader.peek(offset: offset) == " " {
offset += 1
}
return state.reader.peek(offset: offset) == ":"
&& isBlockMappingValueBoundary(state.reader.peek(offset: offset + 1))
}
default:
offset += 1
}
}
return false
}
/// Returns the offset just past the closing `'` of a single-quoted scalar that opened at `open`,
/// treating a doubled `''` as an escaped quote rather than a close.
private func skipSingleQuotedLookahead(after open: Int, state: State) -> Int {
var offset = open + 1
while let character = state.reader.peek(offset: offset) {
if character == "'" {
if state.reader.peek(offset: offset + 1) == "'" {
offset += 2
continue
}
return offset + 1
}
offset += 1
}
return offset
}
/// Returns the offset just past the closing `"` of a double-quoted scalar that opened at `open`,
/// skipping `\`-escaped characters so an escaped quote does not end the scan early.
private func skipDoubleQuotedLookahead(after open: Int, state: State) -> Int {
var offset = open + 1
while let character = state.reader.peek(offset: offset) {
if character == "\\" {
offset += 2
continue
}
if character == "\"" {
return offset + 1
}
offset += 1
}
return offset
}
}