PureYAML/Sources/Parsing/Scanner.Indentation.swift
Mihaela M 3b504a9c3a fix(parsing): keep a tab on a leading blank of a block scalar
A blank line within a block scalar keeps any whitespace past the content
indentation as content, but the leading blank lines, those before the first
content line, were only counted and emitted as empty. So an otherwise-blank
`tab` line under a one-space content indent lost its tab, and a folded scalar
beginning with such a line dropped the leading tab.

Record each leading blank line's whitespace verbatim instead of a count, and
once the content indentation is established emit the surplus past it, the same
way an interior blank line does. A leading blank with no surplus still emits an
empty line, and an empty block scalar (no content indentation) still emits
empty lines for its blanks.

Raises yaml-test-suite value-level conformance to 196/197 (99.5%) with the
parse-level and should-error rejection rates unchanged. 482 tests pass.
2026-06-29 01:52:58 +02:00

297 lines
14 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)
recordPendingBlockScalarBlankIndent(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 {
try applyIncreasedIndentation(width: width, start: start, end: end, state: &state)
} else if state.pendingBlockScalarIndentation, state.pendingBlockScalarIsRootNode, width == current {
// A root block scalar (`>`/`|` as the whole document) has the document node as its
// parent, whose indentation is -1, so content at column 0 (width == current == 0) is
// still more indented than its parent and must establish the block scalar's content
// indentation here, where `width > current` (with the base level read as 0) cannot fire.
// A non-root block scalar (a sequence item or mapping value) keeps its parent's indent as
// the floor, so its under-indented content still falls through to an error, unchanged.
try 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)
}
/// Records a leading blank line's indentation while a block scalar's content indentation is
/// still pending (before its first content line), so an over-indented leading line can be
/// caught once the content indentation is established.
func recordPendingBlockScalarBlankIndent(width _: Int, state: inout State) {
guard state.pendingBlockScalarIndentation, state.blockScalarIndentation == nil else {
return
}
// Compare leading spaces, not the tab-expanded width: a tab on a blank line is not
// indentation, so `␣\t` counts as one space, not a jump to the next tab stop.
state.pendingBlockScalarMaxBlankIndent = max(state.pendingBlockScalarMaxBlankIndent, state.lastLeadingSpaceCount)
}
/// A block scalar's leading empty lines must not be more indented than its content, so reject
/// the scalar if the widest leading blank exceeds the content indentation.
func enforceLeadingBlankIndentation(contentIndent: Int, line: Int, state: State) throws {
guard state.pendingBlockScalarMaxBlankIndent > contentIndent else {
return
}
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: line)
}
/// 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 = blockScalarSurplusPrefix(beyond: blockScalarIndentation, whitespace: state.lastLeadingWhitespace)
}
func scanIndentationWidth(_ state: inout State) throws -> Int {
var width = 0
var leadingSpaces = 0
var sawTab = false
var whitespace = ""
while let character = state.reader.peek() {
if character == " " {
width += 1
if !sawTab {
leadingSpaces += 1
}
whitespace.append(character)
state.reader.advance()
} else if character == "\t" {
sawTab = true
width = nextTabStop(after: width)
whitespace.append(character)
state.reader.advance()
} else {
break
}
}
state.lastLeadingSpaceCount = leadingSpaces
state.lastLeadingWhitespace = whitespace
return width
}
func nextTabStop(after width: Int) -> Int {
((width / 4) + 1) * 4
}
/// The leading-whitespace characters that lie beyond a block scalar's space-only `indent`, kept
/// verbatim so a tab in the content (` \ttext` under a 2-space indent) stays a tab rather than
/// being re-expanded to spaces. Walks the recorded whitespace, counting columns the way the width
/// scan does, and returns the remainder once the indentation columns are covered.
func blockScalarSurplusPrefix(beyond indent: Int, whitespace: String) -> String {
var column = 0
var index = whitespace.startIndex
while index < whitespace.endIndex, column < indent {
let character = whitespace[index]
column = character == "\t" ? nextTabStop(after: column) : column + 1
index = whitespace.index(after: index)
}
return String(whitespace[index...])
}
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 = blockScalarSurplusPrefix(beyond: blockScalarIndentation, whitespace: state.lastLeadingWhitespace)
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 {
// The indicator is the content indentation relative to the block scalar's parent node. For an
// inline mapping value (`- aaa: |2`), the parent mapping sits at an implicit indentation that
// never reached the main stack, so take the deepest applicable implicit level as the base:
// `|2` under a mapping at column 2 makes the content indentation 4. A direct sequence item
// (`- |2`) has no such parent and keeps the stack's current level.
let parentIndentation = state.pendingBlockScalarInlineMappingValue
? max(current, state.validImplicitIndentation.filter { $0 <= width }.max() ?? current)
: current
let blockScalarIndentation = parentIndentation + indicator
guard width >= blockScalarIndentation else {
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: marks.end.line)
}
try enforceLeadingBlankIndentation(contentIndent: blockScalarIndentation, line: marks.end.line, state: state)
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) {
// Each leading blank line carries any whitespace past the (now known) content indentation as
// content: an otherwise-blank `␣\t` line under a one-space indent yields the tab. With no
// content indentation established (an empty block scalar), the line is simply empty.
let indent = state.blockScalarIndentation
for whitespace in state.pendingBlockScalarLeadingBlankWhitespace {
let surplus = indent.map { blockScalarSurplusPrefix(beyond: $0, whitespace: whitespace) } ?? ""
state.append(.scalar(value: surplus, style: .plain))
}
state.pendingBlockScalarLeadingBlankWhitespace = []
}
func applyIncreasedIndentation(
width: Int,
start: PureYAML.Parsing.Mark,
end: PureYAML.Parsing.Mark,
state: inout State,
) throws {
guard state.pendingBlockScalarIndentation else {
state.indentation.append(width)
state.append(.indent(width: width), mark: start, endMark: end)
return
}
try enforceLeadingBlankIndentation(contentIndent: width, line: end.line, state: state)
state.indentation.append(width)
state.append(.indent(width: width), mark: start, endMark: end)
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.pendingBlockScalarLeadingBlankWhitespace = []
state.pendingBlockScalarMaxBlankIndent = 0
state.pendingBlockScalarIsRootNode = false
state.pendingBlockScalarInlineMappingValue = false
}
func closeIndentation(_ state: inout State) {
flushEmptyBlockScalarBlanks(&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)
}
}
/// A block scalar whose only lines are blank never establishes a content indentation, so its
/// blank lines stay buffered and are lost. At stream end, flush them as content wrapped in an
/// indent/dedent pair, so the parser builds a block scalar of those blank lines and chomping
/// applies: `keep: |+` over one blank line keeps a trailing line break, while clip and strip trim
/// it away. (Only the stream-end case is handled here, where no later line constrains ordering.)
func flushEmptyBlockScalarBlanks(_ state: inout State) {
guard state.pendingBlockScalarIndentation, state.blockScalarIndentation == nil,
!state.pendingBlockScalarLeadingBlankWhitespace.isEmpty
else {
return
}
state.append(.indent(width: (state.indentation.last ?? 0) + 1))
emitBufferedBlockScalarLeadingBlanks(&state)
state.append(.dedent(width: state.indentation.last ?? 0))
clearPendingBlockScalarIndentation(&state)
}
}