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.
This commit is contained in:
parent
a5b8986d5e
commit
5a0e6c732e
@ -84,6 +84,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- A flow collection followed by a `: ` indicator on the same line is now parsed as an implicit block
|
||||
mapping key, e.g. `[a, b]: value` or `{a: 1}: value`. The scanner looks ahead from the flow opener
|
||||
to its matching close (skipping quoted scalars so their brackets and colons do not miscount) and,
|
||||
when a same-line `:` follows, emits the implicit-key marker a scalar key would get, so the flow
|
||||
node is parsed as the key through the ordinary mapping path. A block implicit key must lie on one
|
||||
line, so a flow collection spanning a line break before its `:` (`[23 / ]: 42`) stays an error, and
|
||||
a flow node that no `:` follows, or that is a mapping value, is unaffected. Raises yaml-test-suite
|
||||
parse-level conformance to 294/333 (88.3%) with no change to the should-error rejection rate.
|
||||
|
||||
- A block scalar that is the document's own root node now folds or keeps content that begins at
|
||||
column 0, e.g. `>` or `--- |` followed by unindented lines. The document node's indentation is -1,
|
||||
so its content at column 0 is still more indented than its parent, but the scanner read the base
|
||||
|
||||
89
Sources/Parsing/Scanner.FlowKey.swift
Normal file
89
Sources/Parsing/Scanner.FlowKey.swift
Normal file
@ -0,0 +1,89 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -253,6 +253,7 @@ extension PureYAML.Parsing.Scanner {
|
||||
) -> Bool {
|
||||
switch character {
|
||||
case "[":
|
||||
appendFlowImplicitKeyMarkerIfNeeded(state: &state)
|
||||
state.flowDepth += 1
|
||||
state.flowContexts.append(.init(kind: .sequence, isExpectingKey: false))
|
||||
scanFixedToken(.flowSequenceStart, state: &state)
|
||||
@ -266,6 +267,7 @@ extension PureYAML.Parsing.Scanner {
|
||||
}
|
||||
return true
|
||||
case "{":
|
||||
appendFlowImplicitKeyMarkerIfNeeded(state: &state)
|
||||
state.flowDepth += 1
|
||||
state.flowContexts.append(.init(kind: .mapping, isExpectingKey: true))
|
||||
scanFixedToken(.flowMappingStart, state: &state)
|
||||
|
||||
54
Tests/FlowCollectionAsMappingKeyTests.swift
Normal file
54
Tests/FlowCollectionAsMappingKeyTests.swift
Normal file
@ -0,0 +1,54 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// A flow collection followed by a `: ` indicator on the same line is an implicit block mapping key,
|
||||
/// e.g. `[a, b]: value` or `{a: 1}: value`. The scanner detects this by looking ahead from the flow
|
||||
/// opener to the matching close and emits the same implicit-key marker a scalar key gets, so the flow
|
||||
/// node is parsed as the key through the ordinary mapping path. A block implicit key must lie on one
|
||||
/// line, so a flow collection spanning a line break before its `:` is not a key and stays an error.
|
||||
@Suite("Flow collection as mapping key")
|
||||
struct FlowCollectionAsMappingKeyTests {
|
||||
@Test("A flow sequence before a colon is a mapping key")
|
||||
func test_flowSequenceKey() throws {
|
||||
#expect(try PureYAML.parse("[a, b]: c\n") == .mapping(.init([
|
||||
.init(keyNode: .sequence([.string("a"), .string("b")]), value: .string("c")),
|
||||
])))
|
||||
}
|
||||
|
||||
@Test("A flow mapping before a colon is a mapping key")
|
||||
func test_flowMappingKey() throws {
|
||||
#expect(try PureYAML.parse("{a: 1}: b\n") == .mapping(.init([
|
||||
.init(keyNode: .mapping(.init([.init(key: "a", value: .int(1))])), value: .string("b")),
|
||||
])))
|
||||
}
|
||||
|
||||
@Test("A colon inside a quoted scalar in the flow key does not end the lookahead early")
|
||||
func test_quotedColonInsideKey() throws {
|
||||
#expect(try PureYAML.parse("['x: y']: z\n") == .mapping(.init([
|
||||
.init(keyNode: .sequence([.string("x: y")]), value: .string("z")),
|
||||
])))
|
||||
}
|
||||
|
||||
@Test("A flow node not followed by a colon stays an ordinary node, not a key")
|
||||
func test_flowNodeWithoutColonIsNotAKey() throws {
|
||||
#expect(try PureYAML.parse("[a, b, c]\n") == .sequence([.string("a"), .string("b"), .string("c")]))
|
||||
#expect(try PureYAML.parse("key: [a, b]\n") == .mapping(.init([
|
||||
.init(key: "key", value: .sequence([.string("a"), .string("b")])),
|
||||
])))
|
||||
}
|
||||
|
||||
@Test("A flow collection spanning a line break before its colon is not a valid key")
|
||||
func test_multilineFlowKeyRejected() {
|
||||
// The flow sequence runs across two lines, so it cannot be a single-line implicit key.
|
||||
#expect(throws: PureYAML.Parsing.ParseError.self) {
|
||||
try PureYAML.parse("[23\n]: 42\n")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A colon without a following space is not a mapping value, so the flow node is not a key")
|
||||
func test_colonWithoutSpaceIsNotAKey() {
|
||||
#expect(throws: PureYAML.Parsing.ParseError.self) {
|
||||
try PureYAML.parse("[a]:b\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user