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.
This commit is contained in:
Mihaela M 2026-06-26 08:42:16 +02:00
parent 234e0382e6
commit ceb12295a4
6 changed files with 39 additions and 7 deletions

View File

@ -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

View File

@ -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(

View File

@ -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) {

View File

@ -52,6 +52,7 @@ extension PureYAML.Parsing.Scanner {
var validImplicitIndentation: Set<Int> = []
var pendingBlockScalarIndentation = false
var pendingBlockScalarIndentationIndicator: Int?
var pendingBlockScalarLeadingBlankLines = 0
var blockScalarIndentation: Int?
var blockScalarContentPrefix = ""
var tagHandles = [

View File

@ -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

View File

@ -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"))