A `---` or `...` at column 0 ends an active block scalar rather than being consumed as its content. The scanner now closes the scalar's indentation at the marker, dedenting back to the document level, so the token stream stays balanced: the dedent precedes the documentEnd. Previously the scalar's indent lingered past the marker and surfaced as a stray late dedent plus a phantom empty document, and a following directive was misread as a scalar. Fixes suite case W4TN (parse-level conformance 315 -> 316); adds tests.
313 lines
15 KiB
Swift
313 lines
15 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
|
|
|
|
// A document marker (`---` / `...`) at column 0 ends an active block scalar rather than
|
|
// being its content. Close the scalar's indentation here, dedenting back to the document
|
|
// level, so the marker is then scanned balanced at the document level. Without this the
|
|
// scalar's indent lingers past the marker and surfaces later as a stray dedent plus a
|
|
// phantom empty node (the marker still ends the document, but the structure is unbalanced).
|
|
if state.blockScalarIndentation != nil, width == 0, isAtDocumentMarker(state.reader) {
|
|
state.blockScalarIndentation = nil
|
|
state.blockScalarContentPrefix = ""
|
|
while state.indentation.count > 1 {
|
|
state.indentation.removeLast()
|
|
state.append(.dedent(width: state.indentation.last ?? 0), mark: end, endMark: end)
|
|
}
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|