PureYAML/Sources/Parsing/Scanner.Indentation.swift
Mihaela M b80e9c1c63 fix(parsing): comments and over-indented blanks ending block scalars
A block scalar terminated by a less-indented comment line failed with a
spurious 'unexpected indentation': once the smaller indentation ended the
scalar, the comment's own column was processed as a dedent, and because no
indentation level held that width the dedent raised an error. A comment's
indentation does not participate in block structure, so a comment that ends
a block scalar is now left for the main loop to scan, with the block
scalar's own first content line (which may itself begin with '#') still
treated as content.

Separately, a whitespace-only line indented past a block scalar's content
level lost its surplus spaces because the width was consumed before the
line was recognized as blank; the surplus is now preserved as the line's
content, so an over-indented blank contributes the correct value.

Measured against the official YAML test-suite, parse-level conformance
rises from 240/333 (72.1%) to 243/333 (73.0%) with matching value-level
gains, and no regression.
2026-06-27 15:34:13 +02:00

203 lines
8.3 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 {
preserveBlockScalarSurplusOnBlankLine(width: width, state: &state)
return
}
let end = state.reader.mark
let current = state.indentation.last ?? 0
let wasInBlockScalar = state.blockScalarIndentation != nil
if consumeBlockScalarContentIndentation(width: width, state: &state) {
return
}
// An active block scalar that just ended (its content was less indented this line) onto a
// line beginning with '#' is leaving an ordinary comment. A comment's own indentation does
// not participate in block structure, so leave it for the main loop to scan rather than
// forcing its column to match an indentation level, which would dedent onto a width no stack
// level holds and raise a spurious error. The guard on `wasInBlockScalar` keeps a block
// scalar's own first content line that begins with '#' (e.g. `|-` then `# text`) as content.
if wasInBlockScalar, state.reader.peek() == "#" {
return
}
if let indicator = state.pendingBlockScalarIndentationIndicator {
try applyExplicitBlockScalarIndentation(
indicator: indicator,
width: width,
current: current,
marks: (start: start, end: end),
state: &state,
)
} else 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)
clearPendingBlockScalarIndentation(&state)
}
/// A whitespace-only line inside an active block scalar that is indented past the block's
/// content indentation carries the surplus spaces as content (e.g. a 3-space line in a 2-space
/// block contributes `" "`). The width is consumed before the line is recognized as blank, so
/// stash the surplus in the content prefix for the blank-line emission to pick up; otherwise the
/// line collapses to empty and a space is dropped from the scalar's value.
func preserveBlockScalarSurplusOnBlankLine(width: Int, state: inout State) {
guard let blockScalarIndentation = state.blockScalarIndentation, width > blockScalarIndentation else {
return
}
state.blockScalarContentPrefix = String(repeating: " ", count: width - blockScalarIndentation)
}
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" {
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 '#' normally starts a comment line, which must not drive
// indent/dedent processing. Inside a block scalar it is different: when
// the header is still pending or the content is active, a leading '#' is
// literal scalar content, so indentation must be processed so the block
// scalar's indentation resolves and its content lines are consumed.
// Bailing here would leave `blockScalarIndentation` unset and the main
// loop would mis-scan the line as a comment, dropping the content.
if state.reader.peek() == "#", !state.pendingBlockScalarIndentation, state.blockScalarIndentation == nil {
return false
}
return state.flowDepth == 0
}
func consumeBlockScalarContentIndentation(
width: Int,
state: inout State,
) -> Bool {
guard let blockScalarIndentation = state.blockScalarIndentation else {
return false
}
guard width < blockScalarIndentation else {
state.blockScalarContentPrefix = String(repeating: " ", count: width - blockScalarIndentation)
return true
}
state.blockScalarIndentation = nil
state.blockScalarContentPrefix = ""
return false
}
func applyExplicitBlockScalarIndentation(
indicator: Int,
width: Int,
current: Int,
marks: (start: PureYAML.Parsing.Mark, end: PureYAML.Parsing.Mark),
state: inout State,
) throws {
let blockScalarIndentation = current + indicator
guard width >= blockScalarIndentation else {
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: marks.end.line)
}
state.indentation.append(blockScalarIndentation)
state.append(.indent(width: blockScalarIndentation), mark: marks.start, endMark: marks.end)
state.blockScalarIndentation = blockScalarIndentation
state.blockScalarContentPrefix = String(repeating: " ", count: width - blockScalarIndentation)
emitBufferedBlockScalarLeadingBlanks(&state)
clearPendingBlockScalarIndentation(&state)
}
/// Leading blank lines (between the header and the first content line) are
/// buffered until the indent token is emitted, then flushed as empty content
/// so they land after the indent and before the first content line.
func emitBufferedBlockScalarLeadingBlanks(_ state: inout State) {
for _ in 0 ..< state.pendingBlockScalarLeadingBlankLines {
state.append(.scalar(value: "", style: .plain))
}
state.pendingBlockScalarLeadingBlankLines = 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)
guard state.pendingBlockScalarIndentation else {
return
}
state.blockScalarIndentation = width
state.blockScalarContentPrefix = ""
emitBufferedBlockScalarLeadingBlanks(&state)
clearPendingBlockScalarIndentation(&state)
}
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 }
}
func clearPendingBlockScalarIndentation(_ state: inout State) {
state.pendingBlockScalarIndentation = false
state.pendingBlockScalarIndentationIndicator = nil
state.pendingBlockScalarLeadingBlankLines = 0
}
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)
}
}
}