PureYAML/Sources/Parsing/Scanner.BlockScalar.swift
Mihaela Mihaljevic 3db3e87d39 fix(parsing): correct block scalar folding and chomping
Rewrite literal (|) and folded (>) block scalar content to YAML 1.2 section 8.1: preserve interior blank lines, apply 8.1.3 line folding (a single break between equally indented non-empty lines folds to a space, while blank lines and more-indented lines keep their breaks), preserve tabs in content (only block-indentation columns are stripped), and support the + keep chomping indicator alongside clip and strip.

The block scalar is now scanned as a single unit (header line plus all content lines) with indentation auto-detection in Scanner.BlockScalar.swift, mirroring the reference scanner, instead of being reassembled from a lossy per-line token array.

Flips 24 official YAML test suite cases to passing (220/402). Updates the gap manifest, vendored fixtures, count assertions, and the conformance roadmap.
2026-06-23 20:01:03 +02:00

189 lines
7.0 KiB
Swift

extension PureYAML.Parsing.Scanner {
/// Scans a literal (`|`) or folded (`>`) block scalar in full, consuming the
/// header line and every content line, then emits a single header token whose
/// associated value is the final folded and chomped scalar text.
///
/// This mirrors `yaml_parser_scan_block_scalar` / `yaml_parser_scan_block_scalar_breaks`
/// from the reference C scanner: block indentation auto-detection, YAML 1.2 §8.1.3
/// line folding (single break between equally indented non-empty lines becomes a
/// space, blank lines stay literal, more-indented lines keep their breaks), tab
/// preservation inside content, and clip/strip/keep chomping.
func scanBlockScalarHeader(
_ style: PureYAML.Parsing.BlockScalarStyle,
state: inout State,
) {
let start = state.reader.mark
let header = scanBlockScalarIndicators(&state)
skipBlockScalarHeaderTrailer(&state)
let literal = style == .literal
let current = state.indentation.last ?? 0
var indent = header.increment.map { current + $0 } ?? 0
var string = ""
var leadingBreak = ""
var trailingBreaks = ""
var leadingBlank = false
scanBlockScalarBreaks(indent: &indent, breaks: &trailingBreaks, state: &state)
while blockScalarColumn(state) == indent, !state.reader.isAtEnd {
let trailingBlank = isBlank(state.reader.peek())
if !literal, leadingBreak.first == "\n", !leadingBlank, !trailingBlank {
if trailingBreaks.isEmpty {
string.append(" ")
}
leadingBreak = ""
} else {
string += leadingBreak
leadingBreak = ""
}
string += trailingBreaks
trailingBreaks = ""
leadingBlank = isBlank(state.reader.peek())
while let character = state.reader.peek(), !isLineBreak(character) {
string.append(character)
state.reader.advance()
}
leadingBreak = readBlockScalarLineBreak(&state)
scanBlockScalarBreaks(indent: &indent, breaks: &trailingBreaks, state: &state)
}
chompBlockScalarTail(
into: &string,
chomping: header.chomping,
leadingBreak: leadingBreak,
trailingBreaks: trailingBreaks,
)
state.append(
.blockScalarHeader(style: style, chomping: header.chomping, value: string),
mark: start,
endMark: state.reader.mark,
)
state.isAtLineStart = true
}
/// Applies clip/strip/keep chomping to the accumulated content tail.
private func chompBlockScalarTail(
into string: inout String,
chomping: PureYAML.Parsing.BlockScalarChomping,
leadingBreak: String,
trailingBreaks: String,
) {
switch chomping {
case .strip:
break
case .clip:
// Clip keeps a single trailing line break. When the final content line is
// not terminated by a break (end of input), the line is still logically
// line-terminated, so a single newline is appended for non-empty content.
if !leadingBreak.isEmpty {
string += leadingBreak
} else if !string.isEmpty {
string += "\n"
}
case .keep:
string += leadingBreak
string += trailingBreaks
}
}
private func scanBlockScalarIndicators(
_ state: inout State,
) -> (chomping: PureYAML.Parsing.BlockScalarChomping, increment: Int?) {
var chomping = PureYAML.Parsing.BlockScalarChomping.clip
var increment: Int?
state.reader.advance()
while let character = state.reader.peek(), isBlockScalarHeaderCharacter(character) {
if character == "-" {
chomping = .strip
} else if character == "+" {
chomping = .keep
} else if let digit = character.wholeNumberValue, digit > 0 {
increment = digit
}
state.reader.advance()
}
return (chomping, increment)
}
/// Eats trailing blanks and an optional comment up to the end of the header line,
/// then consumes the header's line break.
private func skipBlockScalarHeaderTrailer(_ state: inout State) {
while isBlank(state.reader.peek()) {
state.reader.advance()
}
if state.reader.peek() == "#" {
while let character = state.reader.peek(), !isLineBreak(character) {
state.reader.advance()
}
}
if isLineBreak(state.reader.peek()) {
state.reader.advance()
}
}
/// Mirrors `yaml_parser_scan_block_scalar_breaks`: skips indentation spaces up to
/// `indent` columns (auto-detecting `indent` from the deepest blank-line indentation
/// when it has not been set), collecting the line breaks of empty lines into `breaks`.
///
/// Unlike the reference C scanner, this re-runs the surrounding indentation logic on
/// the line where it stops, so it must not leave the reader partway through a
/// non-content line's leading whitespace. It therefore only consumes the block
/// indentation of the stopping line when that line actually belongs to the block
/// (its indentation reaches `indent`); otherwise it restores the reader to the line
/// start so the outer scanner re-reads the line's indentation.
private func scanBlockScalarBreaks(
indent: inout Int,
breaks: inout String,
state: inout State,
) {
var maxIndent = 0
while true {
let lineStart = state.reader
while indent == 0 || blockScalarColumn(state) < indent {
guard state.reader.peek() == " " else {
break
}
state.reader.advance()
}
if blockScalarColumn(state) > maxIndent {
maxIndent = blockScalarColumn(state)
}
guard isLineBreak(state.reader.peek()) else {
if indent > 0, blockScalarColumn(state) < indent {
state.reader = lineStart
}
break
}
breaks += readBlockScalarLineBreak(&state)
}
if indent == 0 {
let current = state.indentation.last ?? 0
indent = max(maxIndent, current + 1, 1)
}
}
/// Reads a single line break, normalizing CR and CRLF to a single `\n`.
private func readBlockScalarLineBreak(_ state: inout State) -> String {
guard isLineBreak(state.reader.peek()) else {
return ""
}
state.reader.advance()
return "\n"
}
private func blockScalarColumn(_ state: State) -> Int {
state.reader.mark.column - 1
}
private func isBlank(_ character: Character?) -> Bool {
character == " " || character == "\t"
}
}