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 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 = String(repeating: " ", count: width - blockScalarIndentation) } func scanIndentationWidth(_ state: inout State) throws -> Int { var width = 0 var leadingSpaces = 0 var sawTab = false while let character = state.reader.peek() { if character == " " { width += 1 if !sawTab { leadingSpaces += 1 } state.reader.advance() } else if character == "\t" { sawTab = true width = nextTabStop(after: width) state.reader.advance() } else { break } } state.lastLeadingSpaceCount = leadingSpaces 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) } 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) { 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, ) 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.pendingBlockScalarLeadingBlankLines = 0 state.pendingBlockScalarMaxBlankIndent = 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) } } }