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.
213 lines
8.8 KiB
Swift
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"
|
|
}
|
|
}
|