diff --git a/CHANGELOG.md b/CHANGELOG.md index 3263151..21e16f4 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Sources/Parsing/Scanner.Helpers.swift b/Sources/Parsing/Scanner.Helpers.swift index 9c02528..f721f49 100644 --- a/Sources/Parsing/Scanner.Helpers.swift +++ b/Sources/Parsing/Scanner.Helpers.swift @@ -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, diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index cf1c01a..46413b0 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -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 } diff --git a/Sources/Parsing/TokenCursor.swift b/Sources/Parsing/TokenCursor.swift index 4331dbe..8b814cb 100644 --- a/Sources/Parsing/TokenCursor.swift +++ b/Sources/Parsing/TokenCursor.swift @@ -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 } diff --git a/Sources/Parsing/TokenEventParser.Block.swift b/Sources/Parsing/TokenEventParser.Block.swift index 821bd58..c8ea178 100644 --- a/Sources/Parsing/TokenEventParser.Block.swift +++ b/Sources/Parsing/TokenEventParser.Block.swift @@ -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 + } } diff --git a/Tests/BlockScalarChompingTests.swift b/Tests/BlockScalarChompingTests.swift new file mode 100644 index 0000000..d351630 --- /dev/null +++ b/Tests/BlockScalarChompingTests.swift @@ -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")) + } +} diff --git a/Tests/ParsingMultilineScalarTests.swift b/Tests/ParsingMultilineScalarTests.swift index d11a654..cd547d1 100644 --- a/Tests/ParsingMultilineScalarTests.swift +++ b/Tests/ParsingMultilineScalarTests.swift @@ -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