PureYAML/Sources/Parsing/Scanner.Scalars.swift
Mihaela M a5b8986d5e fix(parsing): fold root block scalars with column-0 content
A block scalar that is the document's own root node (`>`, `|`, or after
`---` on the same line) may begin its content at column 0: the document node's
indentation is -1, so column-0 content is still more indented than its parent.
The scanner read the base level as 0 and required content strictly deeper than
it, so the lines fell through indentation handling and were mis-scanned as a
stray plain scalar after the document, raising "expected document boundary".

A root block scalar and an under-indented nested one (`- |` then unindented
content) present identical indentation state at the first content line, so the
two cannot be told apart there. Capture the distinction at header-scan time
instead: a root block scalar has the base indentation and no mapping value,
explicit key, block-entry implicit indent, or flow context introducing it on
its line. Only then is its parent the document; a sequence item or mapping
value keeps its parent's indent as the floor, so its under-indented content is
still rejected, unchanged.

Raises yaml-test-suite parse-level to 291/333 (87.4%) and value-level to
173/191 (90.6%). A regression suite covers the root cases (including a leading
'#' as literal content) and the nested under-indented cases that must error.
410 tests pass.
2026-06-28 17:42:38 +02:00

177 lines
7.4 KiB
Swift

extension PureYAML.Parsing.Scanner {
func scanComment(_ state: inout State) {
let start = state.reader.mark
state.reader.advance()
if state.reader.peek() == " " {
state.reader.advance()
}
let value = state.reader.consume { character in
!isLineBreak(character)
}
state.append(.comment(value), mark: start, endMark: state.reader.mark)
}
func scanQuotedScalar(
_ style: PureYAML.Parsing.ScalarStyle,
state: inout State,
) throws {
let start = state.reader.mark
let quote: Character = style == .doubleQuoted ? "\"" : "'"
state.reader.advance()
var value = ""
var previousWasEscape = false
while let character = state.reader.peek() {
if style == .singleQuoted, character == quote, state.reader.peek(offset: 1) == quote {
value.append(character)
state.reader.advance()
value.append(character)
state.reader.advance()
continue
}
if character == quote, !previousWasEscape {
break
}
value.append(character)
previousWasEscape = character == "\\" && style == .doubleQuoted && !previousWasEscape
state.reader.advance()
if character != "\\" {
previousWasEscape = false
}
// A line that begins with a document marker (`---` / `...`) is forbidden inside a
// multi-line scalar: the marker would be ambiguous with a document boundary.
if isLineBreak(character), isAtDocumentMarker(state.reader) {
throw PureYAML.Parsing.ParseError.documentMarkerInScalar(line: state.reader.mark.line)
}
}
guard state.reader.peek() == quote else {
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: start.line)
}
state.reader.advance()
// Unlike a plain scalar (which consumes the space before a `:` into itself), a quoted scalar
// stops at its closing quote, so the reader sits at any spaces before a `:`. A space between
// an implicit key and its mapping-value indicator is allowed, so look past them.
if isAtMappingValueIndicatorAfterSpaces(state: state), canSynthesizeImplicitBlockKey(state) {
// A block implicit key must lie on a single line, so a quoted scalar that spanned a line
// break (its open and close quotes sit on different lines) cannot be one. The rule is
// block-only: a flow mapping key may span lines, so the check is gated on block context.
// An explicit key (`? ...`) carries no `:` directly after the scalar, so never reaches here.
guard start.line == state.reader.mark.line || state.flowDepth > 0 else {
throw PureYAML.Parsing.ParseError.multilineImplicitKey(line: start.line)
}
state.append(.mappingKey, mark: start, endMark: start)
}
state.append(.scalar(value: value, style: style), mark: start, endMark: state.reader.mark)
}
func scanBlockScalarHeader(
_ style: PureYAML.Parsing.BlockScalarStyle,
state: inout State,
) {
let start = state.reader.mark
var chomping = PureYAML.Parsing.BlockScalarChomping.clip
var indentationIndicator: Int?
state.reader.advance()
while let character = state.reader.peek(), isBlockScalarHeaderCharacter(character) {
if character == "-" {
chomping = .strip
} else if character == "+" {
chomping = .keep
} else if character.isNumber {
indentationIndicator = Int(String(character))
}
state.reader.advance()
}
state.append(
.blockScalarHeader(style: style, chomping: chomping),
mark: start,
endMark: state.reader.mark,
)
state.pendingBlockScalarIndentation = true
state.pendingBlockScalarIndentationIndicator = indentationIndicator
// A root block scalar (the document's own node) has no enclosing collection: it sits at the
// base indentation with no mapping value, explicit key, block-entry implicit indent, or flow
// context introducing it on this line. Only then is its parent the document (indentation -1),
// letting its content begin at column 0. A sequence item or mapping value keeps its parent's
// indent as the floor instead.
state.pendingBlockScalarIsRootNode = state.indentation == [0]
&& !state.sawMappingValueOnLine
&& !state.pendingExplicitKey
&& state.validImplicitIndentation.isEmpty
&& state.flowDepth == 0
}
func scanBlockScalarContent(_ state: inout State) {
let start = state.reader.mark
let value = state.blockScalarContentPrefix + state.reader.consume { character in
!isLineBreak(character)
}
state.blockScalarContentPrefix = ""
state.append(.scalar(value: value, style: .plain), mark: start, endMark: state.reader.mark)
}
func scanNamedToken(
kind: (String) -> PureYAML.Parsing.TokenKind,
missingNameError: (Int) -> PureYAML.Parsing.ParseError,
state: inout State,
) throws {
let start = state.reader.mark
state.reader.advance()
let name = state.reader.consume { character in
!isTokenTerminator(character)
}
guard !name.isEmpty else {
throw missingNameError(start.line)
}
state.append(kind(name), mark: start, endMark: state.reader.mark)
}
func scanTag(_ state: inout State) throws {
let start = state.reader.mark
state.reader.advance()
var value = "!"
if state.reader.peek() == "<" {
value.append(state.reader.advance() ?? "<")
var didClose = false
while let character = state.reader.peek() {
value.append(character)
state.reader.advance()
if character == ">" {
didClose = true
break
}
}
guard didClose else {
throw PureYAML.Parsing.ParseError.unterminatedTag(line: start.line)
}
} else {
value += state.reader.consume { character in
!isTokenTerminator(character)
}
value = expandTag(value, state: state)
}
state.append(.tag(value), mark: start, endMark: state.reader.mark)
}
func scanPlainScalar(_ state: inout State) {
let start = state.reader.mark
var value = ""
while let character = state.reader.peek() {
if shouldEndPlainScalar(character, state: state, accumulated: value) {
break
}
value.append(character)
state.reader.advance()
}
var trimmed = trimTrailingSpaces(value)
let isMappingKey = appendMappingKeyIfNeeded(start: start, state: &state)
if !isMappingKey {
trimmed = scanPlainScalarContinuations(
after: trimmed,
firstLineIndent: start.column - 1,
state: &state,
)
}
state.append(.scalar(value: trimmed, style: .plain), mark: start, endMark: state.reader.mark)
}
}