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.
189 lines
7.0 KiB
Swift
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"
|
|
}
|
|
}
|