fix(parsing): preserve blank lines in block scalars

The scanner dropped every blank line within and after a block scalar, so
a literal block lost internal blank lines, a folded block turned a blank
line into a space rather than a newline, and the keep indicator could not
preserve trailing blank lines. Capture blank lines as empty content in
both the batch scan loop and the streaming TokenCursor loop, which had
drifted as duplicate copies. Rework blockScalarValue to apply literal
versus folded line semantics and clip/strip/keep chomping over the
captured lines; content without blank lines is unchanged. Re-pin the
multiline-scalar test whose expectation encoded the old dropped-blank
behavior, and add a chomping-and-folding test matrix.
This commit is contained in:
Mihaela M 2026-06-26 08:09:45 +02:00
parent 934c9eafa3
commit d3ff3823a5
7 changed files with 127 additions and 16 deletions

View File

@ -24,10 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Classify the block scalar keep chomping indicator (`|+` / `>+`) as a distinct
`keep` chomping instead of silently treating it as clip. Full keep semantics
(preserving trailing blank lines) still depend on scanner blank-line capture,
tracked in #57.
- Preserve blank lines in block scalars. The scanner dropped every blank line
within and after a block scalar, so a literal `|` lost its internal blank
lines, a folded `>` turned a blank line into a space instead of a newline, and
the `|+` / `>+` keep indicator could not preserve trailing blank lines. Blank
lines are now captured as empty content in both the batch and streaming scan
paths (which had duplicated the loop), and `blockScalarValue` applies literal
versus folded line semantics and clip/strip/keep chomping correctly. A block
scalar without blank lines is unchanged.
### Documentation

View File

@ -1,4 +1,16 @@
extension PureYAML.Parsing.Scanner {
/// A blank line inside a block scalar is preserved as empty content so that
/// 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.
func captureBlockScalarBlankLine(_ state: inout State) {
guard state.isAtLineStart, state.blockScalarIndentation != nil else {
return
}
state.append(.scalar(value: "", style: .plain))
}
func startsWith(
_ prefix: String,
_ reader: PureYAML.Parsing.Reader,

View File

@ -9,6 +9,7 @@ extension PureYAML.Parsing {
while !state.reader.isAtEnd {
if isLineBreak(state.reader.peek()) {
state.reader.advance()
captureBlockScalarBlankLine(&state)
state.isAtLineStart = true
continue
}

View File

@ -88,6 +88,7 @@ extension PureYAML.Parsing {
if scanner.isLineBreak(scannerBox.state.reader.peek()) {
scannerBox.state.reader.advance()
scanner.captureBlockScalarBlankLine(&scannerBox.state)
scannerBox.state.isAtLineStart = true
continue
}

View File

@ -240,23 +240,68 @@ extension PureYAML.Parsing.TokenEventParser {
chomping = .clip
}
let separator = style == .folded ? " " : "\n"
let content = lines.joined(separator: separator)
guard !content.isEmpty else {
// Each content line carries its own line break. The folded style folds a
// single break between two non-empty lines into a space, while blank
// lines stay as line breaks. Chomping then trims the trailing breaks.
// For content with no blank lines (the only shape the scanner captures
// today) this is identical to the previous join-and-append behavior.
let body: String = switch style {
case .literal:
lines.map { $0 + "\n" }.joined()
case .folded:
foldedBlockBody(lines)
}
return chompedBlockScalar(body, chomping: chomping)
}
func foldedBlockBody(_ lines: [String]) -> String {
guard let first = lines.first else {
return ""
}
var result = first
var index = 1
while index < lines.count {
if lines[index].isEmpty {
// A run of N blank lines folds to N line breaks: the first break
// is absorbed, the rest are literal.
var blankCount = 0
while index < lines.count, lines[index].isEmpty {
blankCount += 1
index += 1
}
result += String(repeating: "\n", count: blankCount)
if index < lines.count {
result += lines[index]
index += 1
}
} else {
result += " " + lines[index]
index += 1
}
}
return result + "\n"
}
func chompedBlockScalar(
_ body: String,
chomping: PureYAML.Parsing.BlockScalarChomping,
) -> String {
switch chomping {
case .keep:
return body
case .strip:
return content
case .clip, .keep:
// Clip and keep currently coincide: both append the single final
// line break. They diverge only over trailing blank lines, which the
// scanner does not yet capture into `lines`, so keep cannot preserve
// them today. That gap (and the related dropping of internal blank
// lines and folded-paragraph breaks) is tracked separately. Strip is
// the only branch that omits the final break.
return content + "\n"
return blockScalarTrimmingTrailingNewlines(body)
case .clip:
let trimmed = blockScalarTrimmingTrailingNewlines(body)
return trimmed.isEmpty ? "" : trimmed + "\n"
}
}
func blockScalarTrimmingTrailingNewlines(_ value: String) -> String {
var result = value
while result.hasSuffix("\n") {
result.removeLast()
}
return result
}
}

View File

@ -0,0 +1,45 @@
@testable import PureYAML
import Testing
@Suite("Block Scalar Chomping And Folding")
struct BlockScalarChompingTests {
private func value(_ yaml: String) throws -> PureYAML.Model.Value? {
guard case let .mapping(mapping) = try PureYAML.parse(yaml) else {
return nil
}
return mapping["k"]
}
@Test("Literal clip preserves internal blank lines and keeps one trailing newline")
func test_literalClipPreservesInternalBlankAndClipsTrailing() throws {
#expect(try value("k: |\n a\n\n b\n\n\nnext: 1\n") == .string("a\n\nb\n"))
}
@Test("Literal strip preserves internal blank lines and drops trailing newlines")
func test_literalStripDropsTrailing() throws {
#expect(try value("k: |-\n a\n\n b\n\n\nnext: 1\n") == .string("a\n\nb"))
}
@Test("Literal keep preserves internal and trailing blank lines")
func test_literalKeepPreservesTrailingBlankLines() throws {
#expect(try value("k: |+\n a\n\n b\n\n\nnext: 1\n") == .string("a\n\nb\n\n\n"))
}
@Test("Folded turns a blank line into a newline and a single break into a space")
func test_foldedBlankBecomesNewlineAndBreakBecomesSpace() throws {
#expect(try value("k: >\n a\n\n b\nnext: 1\n") == .string("a\nb\n"))
#expect(try value("k: >\n a\n b\nnext: 1\n") == .string("a b\n"))
}
@Test("A run of N blank lines folds to N newlines")
func test_foldedConsecutiveBlankLinesBecomeThatManyNewlines() throws {
#expect(try value("k: >\n a\n\n\n b\nnext: 1\n") == .string("a\n\nb\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"))
#expect(try value("k: |-\n a\n b\nnext: 1\n") == .string("a\nb"))
#expect(try value("k: >\n a\n b\nnext: 1\n") == .string("a b\n"))
}
}

View File

@ -187,9 +187,12 @@ struct ParsingMultilineScalarTests {
""",
))
// A literal block scalar preserves the blank line between the heading and
// the list (YAML 1.2 line 8.1.1.1); clip keeps a single trailing newline.
#expect(root?["description"] == .string(
"""
**Fine-grained access tokens**
* [GitHub App user access tokens](https://docs.github.com)
- dash list item stays text