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.
297 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|