PureYAML/Sources/Parsing/Scanner.Indentation.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

143 lines
5.5 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 let character = state.reader.peek() {
if character == " " {
width += 1
state.reader.advance()
} else if character == "\t" {
// A tab may not provide block indentation. At the start of a block
// line a simple key is allowed, so libyaml does not consume the tab
// as whitespace and the line is malformed (scanner.c:1948-1953). A
// tab is only legal here once it follows content on the same line.
if state.flowDepth == 0, state.simpleKeyAllowed {
throw PureYAML.Parsing.ParseError.tabIndentation(line: state.reader.mark.line)
}
width = nextTabStop(after: width)
state.reader.advance()
} else {
return width
}
}
return width
}
func nextTabStop(after width: Int) -> Int {
((width / 4) + 1) * 4
}
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)
}
}
}