From 735767ced9a7969ebe3c7ad9b85a7a21b0e9a183 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sat, 27 Jun 2026 18:28:57 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 9 ++++++ Sources/Parsing/Scanner.Helpers.swift | 10 ++++++- Sources/Parsing/Scanner.Scalars.swift | 2 +- Sources/Parsing/Scanner.swift | 26 +++++++++++++---- Sources/Parsing/TokenCursor.swift | 1 + Tests/InlineNestedMappingTests.swift | 41 +++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 Tests/InlineNestedMappingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 237ae80..016f5b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Parsing/Scanner.Helpers.swift b/Sources/Parsing/Scanner.Helpers.swift index 7cea67e..9010173 100644 --- a/Sources/Parsing/Scanner.Helpers.swift +++ b/Sources/Parsing/Scanner.Helpers.swift @@ -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 + } } diff --git a/Sources/Parsing/Scanner.Scalars.swift b/Sources/Parsing/Scanner.Scalars.swift index b23a422..cb08931 100644 --- a/Sources/Parsing/Scanner.Scalars.swift +++ b/Sources/Parsing/Scanner.Scalars.swift @@ -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) diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 0a73f35..06897b9 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.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 } diff --git a/Sources/Parsing/TokenCursor.swift b/Sources/Parsing/TokenCursor.swift index 6f079ef..dc7405f 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.sawMappingValueOnLine = false continue } diff --git a/Tests/InlineNestedMappingTests.swift b/Tests/InlineNestedMappingTests.swift new file mode 100644 index 0000000..d89962a --- /dev/null +++ b/Tests/InlineNestedMappingTests.swift @@ -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")), + ]))), + ]))) + } +}