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