From ceb12295a403cf8d42afcccd3a06b58dd8db66ca Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Fri, 26 Jun 2026 08:42:16 +0200 Subject: [PATCH] fix(parsing): preserve leading blank lines in block scalars Blank lines between a block scalar header and its first content line come before the indent token, so the earlier blank-line capture skipped them. Buffer leading blanks while the block indentation is still pending and flush them as empty content once the indent is established, in the shared indentation path used by both scan loops. Also fix folded bodies to emit a break for a leading blank rather than folding it into a leading space. This closes the last block-scalar blank-line edge from #57. --- CHANGELOG.md | 3 +++ Sources/Parsing/Scanner.Helpers.swift | 12 +++++++++--- Sources/Parsing/Scanner.Indentation.swift | 13 +++++++++++++ Sources/Parsing/Scanner.swift | 1 + Sources/Parsing/TokenEventParser.Block.swift | 10 ++++++---- Tests/BlockScalarChompingTests.swift | 7 +++++++ 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5845ec..1bc2169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 scalar without blank lines is unchanged. - Keep a more-indented line inside a folded `>` block literal, with its line breaks preserved, rather than folding it into the surrounding text. +- Preserve leading blank lines between a block scalar header and its first + content line. They precede the indent token, so they are buffered and emitted + as empty content once the content indentation is established. ### Documentation diff --git a/Sources/Parsing/Scanner.Helpers.swift b/Sources/Parsing/Scanner.Helpers.swift index f721f49..430700d 100644 --- a/Sources/Parsing/Scanner.Helpers.swift +++ b/Sources/Parsing/Scanner.Helpers.swift @@ -3,12 +3,18 @@ extension PureYAML.Parsing.Scanner { /// chomping and folding can apply to it. The guard keeps blank lines outside /// block scalars unaffected; `isAtLineStart` is true here only when the /// previous line already ended, so a content line's own terminating break - /// does not emit a blank. + /// does not emit a blank. Blank lines between the header and the first + /// content line precede the indent token, so they are buffered and emitted + /// once the content indentation is established. func captureBlockScalarBlankLine(_ state: inout State) { - guard state.isAtLineStart, state.blockScalarIndentation != nil else { + guard state.isAtLineStart else { return } - state.append(.scalar(value: "", style: .plain)) + if state.blockScalarIndentation != nil { + state.append(.scalar(value: "", style: .plain)) + } else if state.pendingBlockScalarIndentation { + state.pendingBlockScalarLeadingBlankLines += 1 + } } func startsWith( diff --git a/Sources/Parsing/Scanner.Indentation.swift b/Sources/Parsing/Scanner.Indentation.swift index 85573cc..91f3651 100644 --- a/Sources/Parsing/Scanner.Indentation.swift +++ b/Sources/Parsing/Scanner.Indentation.swift @@ -98,9 +98,20 @@ extension PureYAML.Parsing.Scanner { state.append(.indent(width: blockScalarIndentation), mark: marks.start, endMark: marks.end) state.blockScalarIndentation = blockScalarIndentation state.blockScalarContentPrefix = String(repeating: " ", count: width - blockScalarIndentation) + emitBufferedBlockScalarLeadingBlanks(&state) clearPendingBlockScalarIndentation(&state) } + /// Leading blank lines (between the header and the first content line) are + /// buffered until the indent token is emitted, then flushed as empty content + /// so they land after the indent and before the first content line. + func emitBufferedBlockScalarLeadingBlanks(_ state: inout State) { + for _ in 0 ..< state.pendingBlockScalarLeadingBlankLines { + state.append(.scalar(value: "", style: .plain)) + } + state.pendingBlockScalarLeadingBlankLines = 0 + } + func applyIncreasedIndentation( width: Int, start: PureYAML.Parsing.Mark, @@ -114,6 +125,7 @@ extension PureYAML.Parsing.Scanner { } state.blockScalarIndentation = width state.blockScalarContentPrefix = "" + emitBufferedBlockScalarLeadingBlanks(&state) clearPendingBlockScalarIndentation(&state) } @@ -153,6 +165,7 @@ extension PureYAML.Parsing.Scanner { func clearPendingBlockScalarIndentation(_ state: inout State) { state.pendingBlockScalarIndentation = false state.pendingBlockScalarIndentationIndicator = nil + state.pendingBlockScalarLeadingBlankLines = 0 } func closeIndentation(_ state: inout State) { diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 46413b0..0577cdf 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -52,6 +52,7 @@ extension PureYAML.Parsing.Scanner { var validImplicitIndentation: Set = [] var pendingBlockScalarIndentation = false var pendingBlockScalarIndentationIndicator: Int? + var pendingBlockScalarLeadingBlankLines = 0 var blockScalarIndentation: Int? var blockScalarContentPrefix = "" var tagHandles = [ diff --git a/Sources/Parsing/TokenEventParser.Block.swift b/Sources/Parsing/TokenEventParser.Block.swift index 89192c1..be4fb1f 100644 --- a/Sources/Parsing/TokenEventParser.Block.swift +++ b/Sources/Parsing/TokenEventParser.Block.swift @@ -255,11 +255,13 @@ extension PureYAML.Parsing.TokenEventParser { } func foldedBlockBody(_ lines: [String]) -> String { - guard let first = lines.first else { - return "" + guard let firstContent = lines.firstIndex(where: { !$0.isEmpty }) else { + // The block is entirely blank lines: one break each, no folding. + return String(repeating: "\n", count: lines.count) } - var result = first - var index = 1 + // Leading blank lines each contribute a line break before the content. + var result = String(repeating: "\n", count: firstContent) + lines[firstContent] + var index = firstContent + 1 while index < lines.count { if lines[index].isEmpty { // A run of N blank lines folds to N line breaks: the first break diff --git a/Tests/BlockScalarChompingTests.swift b/Tests/BlockScalarChompingTests.swift index d0b8c0a..fa7871d 100644 --- a/Tests/BlockScalarChompingTests.swift +++ b/Tests/BlockScalarChompingTests.swift @@ -42,6 +42,13 @@ struct BlockScalarChompingTests { #expect(try value("k: >\n a\n code\nnext: 1\n") == .string("a\n code\n")) } + @Test("Leading blank lines before the first content line are preserved") + func test_leadingBlankLinesPreserved() throws { + #expect(try value("k: |\n\n a\n b\nnext: 1\n") == .string("\na\nb\n")) + #expect(try value("k: |\n\n\n a\nnext: 1\n") == .string("\n\na\n")) + #expect(try value("k: >\n\n a\n b\nnext: 1\n") == .string("\na b\n")) + } + @Test("A block scalar with no blank lines is unaffected") func test_blockScalarWithoutBlankLinesUnchanged() throws { #expect(try value("k: |\n a\n b\nnext: 1\n") == .string("a\nb\n"))