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:
parent
934c9eafa3
commit
d3ff3823a5
12
CHANGELOG.md
12
CHANGELOG.md
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
45
Tests/BlockScalarChompingTests.swift
Normal file
45
Tests/BlockScalarChompingTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user