The gap-only manifest stopped guarding a case once it was fixed, so later work silently regressed previously-fixed cases (caught only by an external full-suite run, not the gated sweep or full corpus). Replace it with a full manifest of all 402 official cases (Tests/Fixtures/yaml-test-suite-manifest.yaml), each carrying its conformant expectation and a passing/gap status, with every case's files vendored. The gated sweep now asserts every passing case still passes (the regression guard) and every gap still reproduces, so any regression fails the build. The honest gate-enforced count is 329/402 passing (the earlier 348 came from a more lenient one-time oracle). Repairs three regressions the new gate revealed: the bucket-6 tab rule over-rejected tabs in block-scalar content and as separation before a scalar value (root cause: the batch scanner and the incremental TokenCursor had divergent line-start loops; unified onto a single scanStep, fixing 96NN/00, 96NN/01, R4YG, Y79Y/001, DK95/00, DK95/03, DK95/07, 6CA3, Q5MG, J3BT); an anchor on a prior line before an alias key wrongly rejected 26DV; and a tag-only flow key mis-parsed WZ62. Genuine tab-indentation errors (4EJS, Y79Y/000) still reject.
225 lines
9.4 KiB
Swift
225 lines
9.4 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()
|
|
}
|
|
try rejectBlockScalarIndentationTab(indent: indent, state: state)
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Rejects a tab where a block-scalar indentation space is expected, matching
|
|
/// scanner.c:2993-2997 ("found a tab character where an indentation space is
|
|
/// expected"). The tab abuses indentation only when it would itself supply the
|
|
/// block's indent: with a fixed indent, that is any tab below the content column;
|
|
/// while auto-detecting (`indent == 0`), a tab at column 0 (no space established a
|
|
/// content column) is illegal, but a tab after one or more leading spaces is the
|
|
/// first content character of an indented line, so the spaces fix the indent and
|
|
/// the tab is preserved as content.
|
|
private func rejectBlockScalarIndentationTab(indent: Int, state: State) throws {
|
|
guard state.reader.peek() == "\t" else {
|
|
return
|
|
}
|
|
let column = blockScalarColumn(state)
|
|
let abusesIndentation = indent > 0 ? column < indent : column == 0
|
|
if abusesIndentation {
|
|
throw PureYAML.Parsing.ParseError.tabIndentation(line: state.reader.mark.line)
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
}
|
|
}
|