From b80e9c1c63fa7e08335e626b60a2dd9d8e7cf772 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sat, 27 Jun 2026 15:34:13 +0200 Subject: [PATCH] fix(parsing): comments and over-indented blanks ending block scalars A block scalar terminated by a less-indented comment line failed with a spurious 'unexpected indentation': once the smaller indentation ended the scalar, the comment's own column was processed as a dedent, and because no indentation level held that width the dedent raised an error. A comment's indentation does not participate in block structure, so a comment that ends a block scalar is now left for the main loop to scan, with the block scalar's own first content line (which may itself begin with '#') still treated as content. Separately, a whitespace-only line indented past a block scalar's content level lost its surplus spaces because the width was consumed before the line was recognized as blank; the surplus is now preserved as the line's content, so an over-indented blank contributes the correct value. Measured against the official YAML test-suite, parse-level conformance rises from 240/333 (72.1%) to 243/333 (73.0%) with matching value-level gains, and no regression. --- CHANGELOG.md | 7 ++++++ Sources/Parsing/Scanner.Helpers.swift | 5 +++- Sources/Parsing/Scanner.Indentation.swift | 24 ++++++++++++++++++ Tests/BlockScalarTrailingCommentTests.swift | 27 +++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 Tests/BlockScalarTrailingCommentTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c58b41..b6a66f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A block scalar followed by a less-indented comment line no longer fails with a spurious + "unexpected indentation". When a comment ends a block scalar, its own column was processed as + a dedent and, landing on a width no indentation level held, raised an error; a comment's + indentation is now left out of block structure. Relatedly, a whitespace-only line indented + past a block scalar's content level now keeps its surplus spaces as content instead of + collapsing to empty, so such a line contributes the correct value. Lifts official test-suite + parse-level conformance from 240/333 (72.1%) to 243/333 (73.0%) and value-level matches. - A quoted mapping key may now have a space before its `:` (`"key" : value`). A plain key consumes the space before the colon into the scalar, but a quoted key stops at its closing quote, leaving the space, and the implicit-key check looked only at the immediate next diff --git a/Sources/Parsing/Scanner.Helpers.swift b/Sources/Parsing/Scanner.Helpers.swift index d8ee0e0..7cea67e 100644 --- a/Sources/Parsing/Scanner.Helpers.swift +++ b/Sources/Parsing/Scanner.Helpers.swift @@ -11,7 +11,10 @@ extension PureYAML.Parsing.Scanner { return } if state.blockScalarIndentation != nil { - state.append(.scalar(value: "", style: .plain)) + // The prefix holds any surplus indentation past the block's content level for an + // otherwise-empty line (see `preserveBlockScalarSurplusOnBlankLine`); empty otherwise. + state.append(.scalar(value: state.blockScalarContentPrefix, style: .plain)) + state.blockScalarContentPrefix = "" } else if state.pendingBlockScalarIndentation { state.pendingBlockScalarLeadingBlankLines += 1 } diff --git a/Sources/Parsing/Scanner.Indentation.swift b/Sources/Parsing/Scanner.Indentation.swift index 91f3651..b98a08b 100644 --- a/Sources/Parsing/Scanner.Indentation.swift +++ b/Sources/Parsing/Scanner.Indentation.swift @@ -3,15 +3,27 @@ extension PureYAML.Parsing.Scanner { let start = state.reader.mark let width = try scanIndentationWidth(&state) guard shouldProcessIndentation(state) else { + preserveBlockScalarSurplusOnBlankLine(width: width, state: &state) return } let end = state.reader.mark let current = state.indentation.last ?? 0 + let wasInBlockScalar = state.blockScalarIndentation != nil if consumeBlockScalarContentIndentation(width: width, state: &state) { return } + // An active block scalar that just ended (its content was less indented this line) onto a + // line beginning with '#' is leaving an ordinary comment. A comment's own indentation does + // not participate in block structure, so leave it for the main loop to scan rather than + // forcing its column to match an indentation level, which would dedent onto a width no stack + // level holds and raise a spurious error. The guard on `wasInBlockScalar` keeps a block + // scalar's own first content line that begins with '#' (e.g. `|-` then `# text`) as content. + if wasInBlockScalar, state.reader.peek() == "#" { + return + } + if let indicator = state.pendingBlockScalarIndentationIndicator { try applyExplicitBlockScalarIndentation( indicator: indicator, @@ -30,6 +42,18 @@ extension PureYAML.Parsing.Scanner { clearPendingBlockScalarIndentation(&state) } + /// A whitespace-only line inside an active block scalar that is indented past the block's + /// content indentation carries the surplus spaces as content (e.g. a 3-space line in a 2-space + /// block contributes `" "`). The width is consumed before the line is recognized as blank, so + /// stash the surplus in the content prefix for the blank-line emission to pick up; otherwise the + /// line collapses to empty and a space is dropped from the scalar's value. + func preserveBlockScalarSurplusOnBlankLine(width: Int, state: inout State) { + guard let blockScalarIndentation = state.blockScalarIndentation, width > blockScalarIndentation else { + return + } + state.blockScalarContentPrefix = String(repeating: " ", count: width - blockScalarIndentation) + } + func scanIndentationWidth(_ state: inout State) throws -> Int { var width = 0 while let character = state.reader.peek() { diff --git a/Tests/BlockScalarTrailingCommentTests.swift b/Tests/BlockScalarTrailingCommentTests.swift new file mode 100644 index 0000000..83b7bc4 --- /dev/null +++ b/Tests/BlockScalarTrailingCommentTests.swift @@ -0,0 +1,27 @@ +@testable import PureYAML +import Testing + +@Suite("Block scalar indentation edges") +struct BlockScalarTrailingCommentTests { + @Test("A comment less indented than block scalar content ends it without error") + func test_lessIndentedTrailingCommentEndsBlockScalar() throws { + // The `# comment` sits at column 2, below the content indent (column 3), so it ends the + // block scalar and is an ordinary comment rather than driving a dedent onto an invalid + // indentation level. + #expect(try PureYAML.parse("|\n text\n # comment\n") == .string("text\n")) + } + + @Test("An over-indented blank line keeps its surplus spaces as content") + func test_overIndentedBlankLineKeepsSurplusSpaces() throws { + // The middle line carries three spaces against a two-space content indent, so one surplus + // space is preserved as the line's content instead of collapsing to empty. + #expect(try PureYAML.parse("|\n a\n \n b\n") == .string("a\n \nb\n")) + } + + @Test("A block scalar whose own content starts with '#' keeps it as literal text") + func test_blockScalarContentStartingWithHash() throws { + #expect(try PureYAML.parse("strip: |-\n # text\n") == .mapping(.init([ + .init(key: "strip", value: .string("# text")), + ]))) + } +}