PureYAML/Sources/Parsing/Scanner.Indentation.swift
Mihaela Mihaljevic ce0585f77e test(conformance): track all 402 suite cases; fix 3 regressions
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.
2026-06-24 00:16:28 +02:00

133 lines
5.2 KiB
Swift

extension PureYAML.Parsing.Scanner {
func scanIndentation(_ state: inout State) throws {
let start = state.reader.mark
let width = try scanIndentationWidth(&state)
guard shouldProcessIndentation(state) else {
return
}
// Mirror libyaml unrolling the block indent to the line's column before any
// token on the line is scanned, so a dedented line restores the enclosing
// collection's indent. A continuation of a plain scalar never reaches here
// (it is consumed inside the scalar scanner), so this only fires at genuine
// block-line starts.
unrollBlockIndent(toColumn: width, state: &state)
let end = state.reader.mark
let current = state.indentation.last ?? 0
if width > current {
applyIncreasedIndentation(width: width, start: start, end: end, state: &state)
} else if width < current {
try applyDedentation(width: width, start: start, end: end, state: &state)
}
pruneImplicitIndentation(&state)
}
func scanIndentationWidth(_ state: inout State) throws -> Int {
var width = 0
while state.reader.peek() == " " {
width += 1
state.reader.advance()
}
// Block indentation (s-indent) is spaces only: a tab is never indentation
// (scanner.c's scan_to_next_token eats only spaces unconditionally). The tab
// is left under the reader so the token scanner can decide whether it is legal
// in-line separation (before a scalar value or flow node) or an illegal tab
// that supplies the indentation of a nested block node; `rejectTabSeparation`
// and the blank-line handling in `scan` make that call. A whitespace-only line
// (spaces and/or tabs followed by a break) is a blank line and carries no
// indentation token.
return width
}
func shouldProcessIndentation(_ state: State) -> Bool {
guard !state.reader.isAtEnd, !isLineBreak(state.reader.peek()) else {
return false
}
// A leading '#' starts a comment line, which must not drive indent/dedent
// processing. Block scalar content (including lines that begin with '#') is
// consumed in full by the block scalar scanner before control returns here,
// so this check no longer needs to special-case block scalars.
if state.reader.peek() == "#" {
return false
}
return state.flowDepth == 0
}
func applyIncreasedIndentation(
width: Int,
start: PureYAML.Parsing.Mark,
end: PureYAML.Parsing.Mark,
state: inout State,
) {
state.indentation.append(width)
state.append(.indent(width: width), mark: start, endMark: end)
}
func applyDedentation(
width: Int,
start: PureYAML.Parsing.Mark,
end: PureYAML.Parsing.Mark,
state: inout State,
) throws {
while state.indentation.count > 1, width < (state.indentation.last ?? 0) {
state.indentation.removeLast()
state.append(.dedent(width: state.indentation.last ?? 0), mark: end, endMark: end)
}
if canUseImplicitIndentation(width: width, state: state) {
state.indentation.append(width)
state.append(.indent(width: width), mark: start, endMark: end)
}
guard width == state.indentation.last else {
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: end.line)
}
}
func canUseImplicitIndentation(
width: Int,
state: State,
) -> Bool {
width != state.indentation.last
&& width > (state.indentation.last ?? 0)
&& state.validImplicitIndentation.contains(width)
}
func pruneImplicitIndentation(_ state: inout State) {
let current = state.indentation.last ?? 0
state.validImplicitIndentation = state.validImplicitIndentation.filter { $0 <= current }
}
/// Mirrors libyaml's `yaml_parser_roll_indent`: when a block collection opens at
/// `column` (0-based) deeper than the current block indent, push the old indent
/// and adopt the new one. No-op in flow context, matching the C scanner.
func rollBlockIndent(toColumn column: Int, state: inout State) {
guard state.flowDepth == 0, state.blockIndent < column else {
return
}
state.blockIndents.append(state.blockIndent)
state.blockIndent = column
}
/// Mirrors libyaml's `yaml_parser_unroll_indent`: pop block indents until the
/// current block indent is at or below `column`. Called at each block-context
/// line start so the block indent tracks the line's actual nesting.
func unrollBlockIndent(toColumn column: Int, state: inout State) {
guard state.flowDepth == 0 else {
return
}
while state.blockIndent > column {
state.blockIndent = state.blockIndents.popLast() ?? -1
}
}
func closeIndentation(_ state: inout State) {
let mark = state.reader.mark
while state.indentation.count > 1 {
state.indentation.removeLast()
state.append(.dedent(width: state.indentation.last ?? 0), mark: mark, endMark: mark)
}
}
}