PureYAML/Sources/Parsing/LibYAML.Scanner.BlockScalar.swift
Mihaela Mihaljevic 26e09103b4 fix(parsing): accept tabs as block-scalar content past the indentation
The block-scalar break scanner raised 'found a tab character where an
indentation space is expected' for every tab seen while auto-detecting
the content indentation. But a tab at or beyond the content column is
content, not indentation: only a tab below the content column is illegal.

Gate the tab check on a content-column floor (the resolved indent, or the
parent indent + 1 while auto-detecting) instead of firing on every tab in
auto-detect mode. A tab-only line shallower than the content is still
rejected, so tab-as-indentation errors (Y79Y/000 and friends) are
preserved. Surpasses libyaml on 96NN/00, 96NN/01, R4YG, Y79Y/001.
Port score 286 -> 290.
2026-06-24 18:44:25 +02:00

173 lines
7.2 KiB
Swift

extension PureYAML.Parsing.LibYAML.Scanner {
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
/// The accumulation state for `scan_block_scalar`.
private struct BlockState {
var string = ""
var leadingBreak = ""
var trailingBreaks = ""
var leadingBlank = false
}
/// Port of `yaml_parser_scan_block_scalar`. Scans a literal (`|`) or folded
/// (`>`) block scalar: parses the chomping (`+`/`-`) and indentation indicators
/// from the header, auto-detects the indentation if not given, then folds the
/// content (literal keeps breaks, folded joins equally-indented non-empty lines
/// with a space) and applies clip/strip/keep chomping to the trailing breaks.
mutating func scanBlockScalar(literal: Bool) throws -> PureYAML.Parsing.LibYAML.Token {
let startMark = reader.mark
reader.skip()
let header = try readBlockScalarHeader(startMark: startMark)
try consumeBlockHeaderTail(startMark: startMark)
var endMark = reader.mark
// -1 is the "indentation not yet auto-detected" sentinel. libyaml uses 0,
// but a root-level block scalar legitimately detects indentation 0, so 0
// must be a representable detected value rather than the sentinel.
var indent = -1
if header.increment != 0 {
indent = indentEngine.indent >= 0 ? indentEngine.indent + header.increment : header.increment
}
var state = BlockState()
try scanBlockScalarBreaks(indent: &indent, state: &state, startMark: startMark, endMark: &endMark)
while reader.column == indent, reader.peek() != nil {
foldBlockLine(&state, literal: literal)
while let character = reader.peek(), !Classify.isBreakZ(character) {
state.string.unicodeScalars.append(character)
reader.skip()
}
state.leadingBreak += readBlockLine()
try scanBlockScalarBreaks(indent: &indent, state: &state, startMark: startMark, endMark: &endMark)
}
if header.chomping != -1 {
state.string += state.leadingBreak
}
if header.chomping == 1 {
state.string += state.trailingBreaks
}
return PureYAML.Parsing.LibYAML.Token(
type: .scalar(value: state.string, style: literal ? .literal : .folded),
startMark: startMark,
endMark: endMark,
)
}
/// Parse the chomping and indentation indicators in either order.
private mutating func readBlockScalarHeader(startMark: PureYAML.Parsing.Mark) throws -> (chomping: Int, increment: Int) {
var chomping = 0
var increment = 0
if reader.peek() == "+" || reader.peek() == "-" {
chomping = reader.peek() == "+" ? 1 : -1
reader.skip()
if Classify.isDigit(reader.peek()) {
increment = try blockIncrement(startMark: startMark)
}
} else if Classify.isDigit(reader.peek()) {
increment = try blockIncrement(startMark: startMark)
if reader.peek() == "+" || reader.peek() == "-" {
chomping = reader.peek() == "+" ? 1 : -1
reader.skip()
}
}
return (chomping, increment)
}
private mutating func blockIncrement(startMark: PureYAML.Parsing.Mark) throws -> Int {
if reader.peek() == "0" {
throw blockError("found an indentation indicator equal to 0", startMark)
}
let value = digitValue(reader.peek())
reader.skip()
return value
}
/// Eat trailing blanks and a comment to the end of the header line, then the
/// line break.
private mutating func consumeBlockHeaderTail(startMark: PureYAML.Parsing.Mark) throws {
while reader.peek() == " " {
reader.skip()
}
if reader.peek() == "#" {
while !Classify.isBreakZ(reader.peek()) {
reader.skip()
}
}
guard Classify.isBreakZ(reader.peek()) else {
throw blockError("did not find expected comment or line break", startMark)
}
if Classify.isBreak(reader.peek()) {
reader.skipLine()
}
}
/// Port of `yaml_parser_scan_block_scalar_breaks`: eat indentation spaces and
/// blank lines, auto-detecting the indentation level when none was given.
private mutating func scanBlockScalarBreaks(indent: inout Int, state: inout BlockState, startMark: PureYAML.Parsing.Mark, endMark: inout PureYAML.Parsing.Mark) throws {
var maxIndent = 0
endMark = reader.mark
// A tab is content (or separation) once the column reaches the content
// indentation; it is illegal only while still inside the required
// indentation. During auto-detection the floor is the parent indent + 1.
let tabFloor = indent >= 0 ? indent : max(0, indentEngine.indent + 1)
while true {
while indent < 0 || reader.column < indent, reader.peek() == " " {
reader.skip()
}
if reader.column > maxIndent {
maxIndent = reader.column
}
if reader.column < tabFloor, Classify.isTab(reader.peek()) {
throw blockError("found a tab character where an indentation space is expected", startMark)
}
if !Classify.isBreak(reader.peek()) {
break
}
state.trailingBreaks += readBlockLine()
endMark = reader.mark
}
if indent < 0 {
// Auto-detected indentation. A root-level block scalar has a parent
// indent of -1, so the floor is 0 (allowing column-0 content), not
// libyaml's 1. maxIndent >= 0 keeps the result non-negative.
indent = max(maxIndent, indentEngine.indent + 1)
}
}
/// Fold the pending breaks at the start of a content line (folded style joins a
/// single break between non-blank lines into a space; otherwise breaks are
/// kept).
private func foldBlockLine(_ state: inout BlockState, literal: Bool) {
let trailingBlank = Classify.isBlank(reader.peek())
if !literal, state.leadingBreak.first == "\n", !state.leadingBlank, !trailingBlank {
if state.trailingBreaks.isEmpty {
state.string += " "
}
state.leadingBreak = ""
} else {
state.string += state.leadingBreak
state.leadingBreak = ""
}
state.string += state.trailingBreaks
state.trailingBreaks = ""
state.leadingBlank = Classify.isBlank(reader.peek())
}
private mutating func readBlockLine() -> String {
reader.skipLine()
return "\n"
}
private func digitValue(_ scalar: Unicode.Scalar?) -> Int {
guard let scalar, scalar >= "0", scalar <= "9" else {
return 0
}
return Int(scalar.value - Unicode.Scalar("0").value)
}
private func blockError(_ problem: String, _ mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a block scalar", problem: problem, mark: mark)
}
}