PureYAML/Sources/Parsing/Scanner.BlockScalar.swift
Mihaela Mihaljevic 1875d66d46 fix(parsing): parse multi-line and block scalars at the document root
Model libyaml's parser->indent (the enclosing block-collection column, -1 at the bare root) separately from per-line indentation, via a blockIndent stack that rolls on block-sequence entries and mapping keys, unrolls at each block-line start, and resets at document boundaries. Plain-scalar continuation folding now compares against blockIndent, so a root scalar folds its column-0 continuations (single break to space, blank line to newline, per-line whitespace stripped) while a scalar inside a collection still ends at a sibling line. The ':' value indicator deliberately does not roll the block indent.

A '---' document-start marker may now be followed by the document's root node on the same line ('--- foo', '--- [1, 2]', '--- |', '--- >'), and a block scalar whose body sits at the document root's column 0 is captured and ends at the next '---'/'...' marker.

Flips 33 official YAML test suite cases (338/402): 18 false-rejects and 12 value mismatches now match, and 3 invalid inputs (9KBC, ZVH3, 9MQT/01) are now correctly rejected. M7A3 and NB6Z now parse but with mismatched values (separate multi-doc and tab-folding gaps) and stay tracked. Adds root multi-line plain scalar regression tests; updates manifest, fixtures, counts, and roadmap.
2026-06-23 22:56:33 +02:00

213 lines
8.8 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,
) throws {
let start = state.reader.mark
let header = scanBlockScalarIndicators(&state)
skipBlockScalarHeaderTrailer(&state)
let literal = style == .literal
// Mirror libyaml's `parser->indent` as the enclosing block indent: an explicit
// increment is added to `max(parser->indent, 0)` (scanner.c:2863), so a root
// block scalar (block indent -1) measures from column 0, not column -1.
let enclosingIndent = max(state.blockIndent, 0)
var indent = header.increment.map { enclosingIndent + $0 } ?? 0
var string = ""
var leadingBreak = ""
var trailingBreaks = ""
var leadingBlank = false
try scanBlockScalarBreaks(indent: &indent, breaks: &trailingBreaks, state: &state)
while blockScalarColumn(state) == indent, !state.reader.isAtEnd {
// A zero-indent block scalar (its body sits at the document root's column
// 0) ends at a `---`/`...` document marker, which closes the document the
// scalar belongs to. libyaml relies on its indent floor never reaching 0;
// PureYAML lets a root body indent to 0 so the body is captured, so the
// boundary is checked explicitly here. A marker indented past column 0 is
// ordinary content, so this only fires when the body is at the root.
if indent == 0, state.reader.mark.column == 1, isDocumentMarkerAtProbe(state.reader) {
break
}
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)
try 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,
) throws {
var maxIndent = 0
while true {
let lineStart = state.reader
while indent == 0 || blockScalarColumn(state) < indent {
guard state.reader.peek() == " " else {
break
}
state.reader.advance()
}
// A tab where a block-scalar indentation space is expected is rejected,
// matching scanner.c:2993-2997 ("found a tab character where an
// indentation space is expected"). The tab only abuses indentation when
// it sits below the block's indent column (or while indent is still
// being auto-detected).
if state.reader.peek() == "\t", indent == 0 || blockScalarColumn(state) < indent {
throw PureYAML.Parsing.ParseError.tabIndentation(line: state.reader.mark.line)
}
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 {
// The auto-detected content indent is at least `parser->indent + 1`
// (scanner.c:3014). At the bare document root the block indent is -1, so
// the floor is 0 and a column-0 body is captured as content (its end is a
// document marker, handled in the content loop); inside any collection the
// floor is at least 1, matching libyaml's additional `< 1` clamp.
indent = max(maxIndent, state.blockIndent + 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"
}
}