fix(parsing): reject inline nested block mappings

Block style permits only one mapping pair per line, so a scalar followed
by ':' after a block-level value on the same line (a: b: c, a: 'b': c,
x: { y: z }in: valid, or trailing content after a quoted value) is not a
new key but a forbidden inline nested mapping. The scanner synthesized an
implicit key for any scalar followed by ':', regardless of position, so
these parsed as nested mappings instead of failing.

The scanner now records when a line already holds a block-level mapping
pair and suppresses a further implicit key on that line, in plain and
quoted scalars alike. The flag is set only for an implicit-key ':' (one
preceded by its key on the same line): a ':' that opens its line is an
explicit- or null-key value indicator, whose value may be a compact
mapping (? k then : a: 1), so that, flow mappings, next-line nesting, and
colons inside scalars stay valid.

Measured against the official YAML test-suite, parse-level conformance
rises from 256/333 (76.9%) to 260/333 (78.1%) with no regression.
This commit is contained in:
Mihaela M 2026-06-27 18:28:57 +02:00
parent fcba4e1f57
commit 735767ced9
6 changed files with 82 additions and 7 deletions

View File

@ -23,6 +23,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- An inline nested block mapping is now rejected. Block style allows only one mapping pair per
line, so a scalar followed by `:` after a block-level value on the same line (`a: b: c`,
`a: 'b': c`, `x: { y: z }in: valid`, or trailing content after a quoted value) no longer parses
as a nested mapping. The scanner suppresses the implicit key it would otherwise synthesize once
the line already holds a pair. A value indicator that opens its line is an explicit- or null-key
indicator, whose value may legitimately be a compact mapping (`? k` then `: a: 1`), so that, flow
mappings (`{a: b, c: d}`), next-line nesting, and colons inside scalars are unaffected. Lifts
official test-suite parse-level conformance from 256/333 (76.9%) to 260/333 (78.1%), with no
regression.
- A `...` document-end marker no longer accepts content on its own line. Only a comment or blank
may follow the marker, so `... invalid` is now rejected; the scanner previously skipped the rest
of the line. A bare `...`, a trailing comment (`... # done`), and a `...` not followed by a

View File

@ -311,10 +311,18 @@ extension PureYAML.Parsing.Scanner {
start: PureYAML.Parsing.Mark,
state: inout State,
) -> Bool {
guard isAtMappingValueIndicator(state: state) else {
guard isAtMappingValueIndicator(state: state), canSynthesizeImplicitBlockKey(state) else {
return false
}
state.append(.mappingKey, mark: start, endMark: start)
return true
}
/// Whether a scalar followed by `:` may start an implicit mapping key here. In block style a
/// line holds at most one mapping pair, so once a block-level `:` has appeared on the line a
/// further key would form a forbidden inline nested mapping; in flow style several pairs per
/// line are allowed, so the restriction does not apply.
func canSynthesizeImplicitBlockKey(_ state: State) -> Bool {
state.flowDepth > 0 || !state.sawMappingValueOnLine
}
}

View File

@ -45,7 +45,7 @@ extension PureYAML.Parsing.Scanner {
// Unlike a plain scalar (which consumes the space before a `:` into itself), a quoted scalar
// stops at its closing quote, so the reader sits at any spaces before a `:`. A space between
// an implicit key and its mapping-value indicator is allowed, so look past them.
if isAtMappingValueIndicatorAfterSpaces(state: state) {
if isAtMappingValueIndicatorAfterSpaces(state: state), canSynthesizeImplicitBlockKey(state) {
state.append(.mappingKey, mark: start, endMark: start)
}
state.append(.scalar(value: value, style: style), mark: start, endMark: state.reader.mark)

View File

@ -12,6 +12,7 @@ extension PureYAML.Parsing {
captureBlockScalarBlankLine(&state)
state.isAtLineStart = true
state.sawContentOnLine = false
state.sawMappingValueOnLine = false
continue
}
@ -62,12 +63,18 @@ extension PureYAML.Parsing.Scanner {
var pendingBlockScalarLeadingBlankLines = 0
var blockScalarIndentation: Int?
var blockScalarContentPrefix = ""
// Whether a real token has been scanned on the current physical line. Reset when the scan
// loop consumes a line break and set as each token is scanned, so the scanner can tell a
// comment that opens a line (valid) from one glued to earlier content on it (invalid). A
// 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.
/// Whether a real token has been scanned on the current physical line. Reset when the scan
/// loop consumes a line break and set as each token is scanned, so the scanner can tell a
/// comment that opens a line (valid) from one glued to earlier content on it (invalid). A
/// 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
// 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
// (`a: b: c`). Reset at each line break. Flow `:` do not set it, since flow allows several
// pairs on a line (`{a: b, c: d}`).
var sawMappingValueOnLine = false
var tagHandles = [
"!": "!",
"!!": "tag:yaml.org,2002:",
@ -127,7 +134,16 @@ extension PureYAML.Parsing.Scanner {
column: state.reader.mark.column,
)
}
let hadContentOnLine = state.sawContentOnLine
state.sawContentOnLine = 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-
// or null-key value indicator, whose value may legitimately be a compact mapping, so it does
// not set the flag.
if character == ":", hadContentOnLine, state.flowDepth == 0, isAtMappingValueIndicator(state: state) {
state.sawMappingValueOnLine = true
}
if try scanLineIndicatorToken(character, state: &state) {
return
}

View File

@ -92,6 +92,7 @@ extension PureYAML.Parsing {
scanner.captureBlockScalarBlankLine(&scannerBox.state)
scannerBox.state.isAtLineStart = true
scannerBox.state.sawContentOnLine = false
scannerBox.state.sawMappingValueOnLine = false
continue
}

View File

@ -0,0 +1,41 @@
@testable import PureYAML
import Testing
@Suite("Inline nested block mappings are rejected")
struct InlineNestedMappingTests {
@Test("A second mapping pair on a block line is rejected", arguments: [
"a: b: c: d\n", // a plain value followed by another key
"a: 'b': c\n", // a quoted value followed by another key
"key2: \"q\" no key: nor value\n", // trailing content after a quoted value
"x: { y: z }in: valid\n", // trailing content after a flow value
])
func test_inlineNestedMappingRejected(_ yaml: String) throws {
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parse(yaml)
}
}
@Test("Mappings spread across lines, and flow mappings, stay valid", arguments: [
"a: b\nc: d\n", // two pairs on separate lines
"a:\n b: c\n", // a nested mapping on the next line
"- key: value\n", // a sequence item that is a mapping
"{a: b, c: d}\n", // a flow mapping with several pairs on one line
"a: http://x\n", // a colon inside a plain scalar value
])
func test_validMappingsStillParse(_ yaml: String) throws {
_ = try PureYAML.parse(yaml)
}
@Test("An explicit key's value may be a compact mapping on the value line")
func test_explicitKeyCompactMappingValue() throws {
// The `:` opens the line, so it is an explicit-key value indicator whose value may be a
// compact block mapping, unlike an implicit key's same-line value.
let value = try PureYAML.parse("? k\n: minLength: 14\n pattern: x\n")
#expect(value == .mapping(.init([
.init(keyNode: .string("k"), value: .mapping(.init([
.init(key: "minLength", value: .int(14)),
.init(key: "pattern", value: .string("x")),
]))),
])))
}
}