PureYAML/Sources/Parsing/Scanner.Helpers.swift
Mihaela M 4b06f6e5fc fix(parsing): end an inline item value at a sibling block entry
A block entry with an inline node recorded only the node's indent (one past
the dash) as a valid implicit level, not the dash's own indent. So a plain
scalar that is an inline sequence item value could not tell a sibling entry
that returns to the sequence indent from its own content, and a compact nested
sequence as an explicit key's value folded into one scalar: `? a` / `: - b` /
`  - - c` / `   - d` became `{a: [b -  - c - d]}` instead of `{a: [b, [c, d]]}`.

Record the entry's own indent as a sibling level too, so the continuation
stops there. Skip it under a pending explicit key, whose own sequence key
drives the implicit indentation differently (an explicit key whose key is a
sequence, `? - x`, must keep parsing). A block entry more indented than its
container is still plain content, so a wrapped value beginning a line with
`- ` folds in. The block-entry recording moved to a helper to keep the file
within its length limit.

Brings yaml-test-suite value-level conformance to 197/197 (100.0%) with the
parse-level and should-error rejection rates unchanged. 485 tests pass.
2026-06-29 02:09:57 +02:00

374 lines
15 KiB
Swift

extension PureYAML.Parsing.Scanner {
/// A blank line inside a block scalar is preserved as empty content so that
/// chomping and folding can apply to it. The guard keeps blank lines outside
/// block scalars unaffected; `isAtLineStart` is true here only when the
/// previous line already ended, so a content line's own terminating break
/// does not emit a blank. Blank lines between the header and the first
/// content line precede the indent token, so they are buffered and emitted
/// once the content indentation is established.
func captureBlockScalarBlankLine(_ state: inout State) {
guard state.isAtLineStart else {
return
}
if state.blockScalarIndentation != nil {
// The prefix holds any surplus indentation past the block's content level for an
// otherwise-empty line (see `preserveBlockScalarSurplusOnBlankLine`); empty otherwise.
state.append(.scalar(value: state.blockScalarContentPrefix, style: .plain))
state.blockScalarContentPrefix = ""
} else if state.pendingBlockScalarIndentation {
state.pendingBlockScalarLeadingBlankWhitespace.append(state.lastLeadingWhitespace)
}
}
func startsWith(
_ prefix: String,
_ reader: PureYAML.Parsing.Reader,
) -> Bool {
for (offset, character) in prefix.enumerated() {
guard reader.peek(offset: offset) == character else {
return false
}
}
return true
}
func isLineBreak(_ character: Character?) -> Bool {
character == "\n" || character == "\r" || character == "\r\n"
}
func isBlockEntry(_ reader: PureYAML.Parsing.Reader) -> Bool {
reader.peek() == "-" && isIndicatorBoundary(reader.peek(offset: 1))
}
func isIndicatorBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
// A tab is separation white space in YAML (only indentation forbids it), so it ends an
// indicator just as a space does: `---\tscalar`, `-\titem`, `?\tkey`.
return character == " " || character == "\t" || isLineBreak(character)
}
func isMappingValueBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
// A tab is separation white space, so it follows a `:` value indicator as a space does.
return character == " "
|| character == "\t"
|| isLineBreak(character)
|| character == ","
|| character == "]"
|| character == "}"
}
func isBlockMappingValueBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
return character == " " || character == "\t" || isLineBreak(character)
}
func isTokenTerminator(_ character: Character) -> Bool {
character == " "
|| isLineBreak(character)
|| character == ","
|| character == "["
|| character == "]"
|| character == "{"
|| character == "}"
|| character == "#"
}
func canStartAnchorName(_ character: Character?) -> Bool {
guard let character else {
return false
}
return !isTokenTerminator(character)
&& character != "*"
&& character != "&"
&& character != "!"
&& character != "\""
&& character != "'"
}
func hasNodeOnSameLine(afterCurrentPositionIn reader: PureYAML.Parsing.Reader) -> Bool {
var offset = 0
while reader.peek(offset: offset) == " " {
offset += 1
}
guard let character = reader.peek(offset: offset) else {
return false
}
return !isLineBreak(character) && character != "#"
}
func isFlowDelimiter(_ character: Character) -> Bool {
character == ","
|| character == "["
|| character == "]"
|| character == "{"
|| character == "}"
}
func isBlockScalarHeaderCharacter(_ character: Character) -> Bool {
character == "+"
|| character == "-"
|| character.isNumber
}
func trimTrailingSpaces(_ value: String) -> String {
var end = value.endIndex
while end > value.startIndex {
let previous = value.index(before: end)
guard value[previous] == " " else {
break
}
end = previous
}
return String(value[..<end])
}
func scanPlainScalarContinuations(
after firstLine: String,
firstLineIndent: Int,
state: inout State,
) -> String {
var value = firstLine
while true {
var probe = state.reader
guard isLineBreak(probe.peek()) else {
return value
}
probe.advance()
let blankLineCount = consumeBlankLines(&probe)
_ = probe.consume { character in
character == " "
}
let indentationWidth = probe.mark.column - 1
guard !probe.isAtEnd, !isLineBreak(probe.peek()), probe.peek() != "#" else {
return value
}
// A plain scalar never spans a document marker (`---` / `...`); stop the continuation at
// one so it begins (or ends) the document rather than folding into the scalar's value.
if indentationWidth == 0, isAtDocumentMarker(probe) {
return value
}
let baseIndentation = plainScalarContinuationBaseIndentation(state: state, firstLineIndent: firstLineIndent)
guard indentationWidth > baseIndentation else {
return value
}
if isBlockEntry(probe), state.validImplicitIndentation.contains(indentationWidth) {
return value
}
var continuationState = state
continuationState.reader = probe
let continuation = scanPlainScalarContinuationLine(&continuationState)
if isAtMappingValueIndicator(state: continuationState) {
// A continuation line ending at `:` is normally a new implicit key, so the scalar
// stops before it. Two cases keep folding instead, with the `:` left to be scanned as
// the value indicator: a block explicit key (`? foo` then `bar :`), where the line is
// still the key's content, and any flow scalar, since a flow key may span lines
// (`{ multi`/`line: v }`) where a block implicit key may not.
let foldsAcrossMappingValue = state.flowDepth > 0 || state.pendingExplicitKey
guard foldsAcrossMappingValue else {
return value
}
state = continuationState
value += blankLineCount == 0 ? " " : String(repeating: "\n", count: blankLineCount)
value += continuation
return value
}
state = continuationState
if blankLineCount == 0 {
value += " "
} else {
value += String(repeating: "\n", count: blankLineCount)
}
value += continuation
}
}
/// Advance `probe` over a run of blank lines (lines that are empty or only spaces), returning
/// how many were consumed; the probe is left at the start of the next non-blank line.
func consumeBlankLines(_ probe: inout PureYAML.Parsing.Reader) -> Int {
var count = 0
while true {
var blankProbe = probe
// A line of only white space is empty for folding, including one that holds a tab: a tab
// is not indentation, but a whitespace-only line is still blank, so consume it here.
_ = blankProbe.consume { character in
character == " " || character == "\t"
}
guard isLineBreak(blankProbe.peek()) else {
break
}
blankProbe.advance()
probe = blankProbe
count += 1
}
return count
}
/// Whether the reader is positioned at a document marker (`---` or `...`) followed by an
/// indicator boundary, i.e. the marker that starts or ends a document.
func isAtDocumentMarker(_ reader: PureYAML.Parsing.Reader) -> Bool {
(startsWith("---", reader) || startsWith("...", reader)) && isIndicatorBoundary(reader.peek(offset: 3))
}
func plainScalarContinuationBaseIndentation(state: State, firstLineIndent: Int) -> Int {
// When the scalar begins exactly at the current indentation top, it is a block value on its
// own line, so its own indent was pushed onto the stack; its continuation lines sit at that
// same indent, and the level they must exceed is the enclosing block's: the indent below on
// the stack, or -1 at the document root, where nothing encloses the scalar and a
// continuation may sit at column 1. For an inline value the top of the stack already is the
// enclosing block's indent, so it is used directly.
if state.indentation.last == firstLineIndent {
return state.indentation.count >= 2 ? state.indentation[state.indentation.count - 2] : -1
}
return state.indentation.last ?? 0
}
func scanPlainScalarContinuationLine(_ state: inout State) -> String {
// Leading white space beyond the line's indentation folds away in a plain scalar. A tab is
// not indentation, so the indentation scan leaves it here; skip it (and any spaces after it)
// so a continuation line like `\t3rd` joins as `3rd`, not `\t3rd`.
while let character = state.reader.peek(), character == " " || character == "\t" {
state.reader.advance()
}
var value = ""
while let character = state.reader.peek(), !isLineBreak(character) {
if character == " ", state.reader.peek(offset: 1) == "#" {
break
}
if character == "#", value.isEmpty || value.last == " " {
break
}
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
break
}
if state.flowDepth > 0, isFlowDelimiter(character) {
break
}
value.append(character)
state.reader.advance()
}
return trimTrailingSpaces(value)
}
func skipLine(_ state: inout State) {
_ = state.reader.consume { character in
!isLineBreak(character)
}
}
func expandTag(
_ tag: String,
state: State,
) -> String {
let handles = state.tagHandles.keys.sorted { first, second in
first.count > second.count
}
for handle in handles where tag.hasPrefix(handle) {
let suffix = tag.dropFirst(handle.count)
return (state.tagHandles[handle] ?? handle) + suffix
}
return tag
}
func shouldEndPlainScalar(
_ character: Character,
state: State,
accumulated: String,
) -> Bool {
if isLineBreak(character) {
return true
}
// A comment opens after white space, a tab as well as a space, so a `#` preceded by either
// ends the plain scalar: `d\t#X` is the value `d` with a trailing comment.
if character == " " || character == "\t", state.reader.peek(offset: 1) == "#" {
return true
}
if character == "#", accumulated.isEmpty || accumulated.last == " " || accumulated.last == "\t" {
return true
}
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
return true
}
if state.flowDepth > 0, isFlowDelimiter(character) {
return true
}
return false
}
func shouldEndPlainScalarAtMappingValue(state: State) -> Bool {
if state.flowDepth > 0 {
return state.isExpectingFlowMappingKey
|| isMappingValueBoundary(state.reader.peek(offset: 1))
}
return isBlockMappingValueBoundary(state.reader.peek(offset: 1))
}
func isAtMappingValueIndicator(state: State) -> Bool {
state.reader.peek() == ":" && shouldEndPlainScalarAtMappingValue(state: state)
}
/// Whether a mapping-value indicator (`: ` or `:` at line end) follows, allowing spaces before
/// the colon. Used after a quoted scalar, where the reader stops at the closing quote and any
/// separating spaces remain, unlike a plain scalar that ends at the colon itself.
func isAtMappingValueIndicatorAfterSpaces(state: State) -> Bool {
var offset = 0
while state.reader.peek(offset: offset) == " " {
offset += 1
}
return state.reader.peek(offset: offset) == ":"
&& isBlockMappingValueBoundary(state.reader.peek(offset: offset + 1))
}
@discardableResult
func appendMappingKeyIfNeeded(
start: PureYAML.Parsing.Mark,
state: inout State,
) -> Bool {
guard isAtMappingValueIndicator(state: state), canSynthesizeImplicitBlockKey(state) else {
return false
}
state.append(.mappingKey, mark: start, endMark: start)
return true
}
/// Whether a scalar followed by `:` may start an implicit mapping key here. In block style a
/// line holds at most one mapping pair, so once a block-level `:` has appeared on the line a
/// further key would form a forbidden inline nested mapping; in flow style several pairs per
/// line are allowed, so that restriction does not apply. In flow a scalar that is the content of
/// an open explicit key (`?`) is the key itself, not a new implicit key.
/// Records the implicit indentation levels a block entry with an inline node introduces: the
/// node's own indent (one past the dash), and the dash's indent as a sibling level so an inline
/// item's plain value stops folding at a later entry that returns to it (`: -␣b` then ` - - c`).
/// The sibling level is skipped under a pending explicit key (`? - x`), whose sequence key drives
/// implicit indentation itself.
func recordBlockEntryImplicitIndentation(start: PureYAML.Parsing.Mark, state: inout State) {
guard hasNodeOnSameLine(afterCurrentPositionIn: state.reader) else {
return
}
state.validImplicitIndentation.insert(start.column + 1)
if !state.pendingExplicitKey {
state.validImplicitIndentation.insert(start.column - 1)
}
}
func canSynthesizeImplicitBlockKey(_ state: State) -> Bool {
if state.flowDepth > 0 {
return state.flowContexts.last?.hasExplicitKey != true
}
return !state.sawMappingValueOnLine
}
}