PureYAML/Sources/Parsing/Scanner.Directives.swift
Mihaela M 455cd07f4a fix(parsing): keep a root node that begins on the document-marker line
A document-start marker scan skipped the rest of the '---' line
unconditionally, so a root node written on that same line was discarded:
a block-scalar header ('--- >' / '--- |') was dropped and its body
mis-read as a plain scalar (folded to spaces, no trailing newline), and a
same-line plain or flow node was lost entirely. The document start now
skips only trailing spaces and leaves any real same-line content for the
parser; an empty rest-of-line or a comment is still consumed, and a node
on the line below the marker is unaffected. Document end ('...') still
takes no same-line node.

Measured against the official YAML test-suite, value-level conformance
rises from 130/170 (76.5%) to 135/169 (79.9%) with no regression.
Parse-level is unchanged: these documents already parsed, just to the
wrong tree, which only the value-level metric exposes.
2026-06-26 20:50:24 +02:00

96 lines
3.6 KiB
Swift

extension PureYAML.Parsing.Scanner {
func scanDirectiveOrDocumentMarker(_ state: inout State) throws -> Bool {
guard state.indentation.last == 0 else {
return false
}
if state.reader.peek() == "%" {
try scanDirective(&state)
return true
}
if state.reader.peek() == "#" {
return false
}
if startsWith("---", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
scanDocumentMarker(.documentStart, state: &state)
resetForDocumentStart(&state)
return true
}
if startsWith("...", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
guard !state.isDocumentClosed else {
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line)
}
scanDocumentMarker(.documentEnd, state: &state)
resetForDocumentEnd(&state)
return true
}
guard !state.isDocumentClosed else {
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line)
}
return false
}
func scanDocumentMarker(
_ kind: PureYAML.Parsing.TokenKind,
state: inout State,
) {
let start = state.reader.mark
state.reader.advance()
state.reader.advance()
state.reader.advance()
state.append(kind, mark: start, endMark: state.reader.mark)
// The document's root node may begin on the marker's own line (e.g. `--- >`, `--- foo`,
// `--- {a: 1}`, `--- !!str x`). After a document start, skip only the spaces and leave any
// real content for the main loop to scan; an empty rest-of-line or a comment is consumed.
// A document end (`...`) takes no same-line node, so its line is always skipped.
if case .documentStart = kind {
while state.reader.peek() == " " {
state.reader.advance()
}
if let next = state.reader.peek(), !isLineBreak(next), next != "#" {
state.isAtLineStart = false
return
}
}
skipLine(&state)
}
func resetForDocumentStart(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = true
state.isDocumentClosed = false
}
func resetForDocumentEnd(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = false
state.isDocumentClosed = true
}
func scanDirective(_ state: inout State) throws {
let start = state.reader.mark
let line = state.reader.consume { character in
!isLineBreak(character)
}
let parts = line.split(separator: " ").map(String.init)
guard let name = parts.first else {
return
}
guard !state.hasDocumentContent, !state.hasDocumentStartMarker, !state.isDocumentClosed else {
throw PureYAML.Parsing.ParseError.unsupportedDirective(name: name, line: start.line)
}
switch name {
case "%YAML":
guard parts.indices.contains(1), parts[1] == "1.1" || parts[1] == "1.2" else {
throw PureYAML.Parsing.ParseError.incompatibleYAMLDirective(line: start.line)
}
case "%TAG":
guard parts.count >= 3 else {
return
}
state.tagHandles[parts[1]] = parts[2]
default:
throw PureYAML.Parsing.ParseError.unsupportedDirective(name: name, line: start.line)
}
}
}