PureYAML/Sources/Parsing/Scanner.Directives.swift
Mihaela M 7e89ba1877 fix(parsing): allow a directive after a document-end marker
A directive following a document-end marker ('...') begins the next
document's directive section, but the scanner rejected it: the
isDocumentClosed flag set by '...' tripped the directive guard, so a
multi-document stream where each document carries its own directives
('%TAG ...' / '--- doc' / '...' / '%TAG ...' / '--- doc') failed.

A directive now reopens the stream when it follows '...', clearing the
closed flag, while a directive after document content within the same
document stays rejected via the content guard. The directive still
requires its own '---', so a directive after '...' with no document start
is still an error. Multi-document streams already composed through the
stream mode; this removes the scanner-side block on per-document
directives.

Measured against the official YAML test-suite, parse-level conformance
rises from 278/333 (83.5%) to 283/333 (85.0%) with no regression.
2026-06-28 02:09:01 +02:00

128 lines
5.4 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)) {
try 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)
}
// A `...` ends a document, so a directive still awaiting its `---` never gets one.
try enforceDirectiveHasDocument(state)
try 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,
) throws {
let start = state.reader.mark
state.reader.advance()
state.reader.advance()
state.reader.advance()
state.append(kind, mark: start, endMark: state.reader.mark)
while state.reader.peek() == " " {
state.reader.advance()
}
if let next = state.reader.peek(), !isLineBreak(next), next != "#" {
// The marker's own line carries real content. A document start may carry the root node
// here (e.g. `--- >`, `--- foo`, `--- {a: 1}`), so leave it for the main loop to scan.
if case .documentStart = kind {
state.isAtLineStart = false
return
}
// A document end (`...`) takes no same-line node; only a comment or blank may follow it.
throw PureYAML.Parsing.ParseError.contentAfterDocumentEnd(line: state.reader.mark.line)
}
skipLine(&state)
}
func resetForDocumentStart(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = true
state.isDocumentClosed = false
state.hasYAMLDirective = false
state.pendingDirectiveLine = nil
}
/// A directive must be followed by an explicit `---` document start. Called at a document end
/// and at stream end, where a still-pending directive means none ever came.
func enforceDirectiveHasDocument(_ state: State) throws {
if let line = state.pendingDirectiveLine {
throw PureYAML.Parsing.ParseError.directiveWithoutDocument(line: line)
}
}
func resetForDocumentEnd(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = false
state.isDocumentClosed = true
state.hasYAMLDirective = false
}
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
}
// A directive after a document-end marker (`...`) begins the next document's directive
// section, so it reopens the stream for a new document rather than being rejected.
if state.isDocumentClosed {
state.isDocumentClosed = false
}
guard !state.hasDocumentContent, !state.hasDocumentStartMarker else {
throw PureYAML.Parsing.ParseError.unsupportedDirective(name: name, line: start.line)
}
// Every directive must be followed by an explicit `---`; record that one is now awaiting it.
state.pendingDirectiveLine = 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)
}
// `%YAML` takes exactly one parameter, the version; only a comment may follow it.
guard parts.count == 2 || parts[2].hasPrefix("#") else {
throw PureYAML.Parsing.ParseError.malformedDirective(name: name, line: start.line)
}
// At most one `%YAML` directive is allowed per document.
guard !state.hasYAMLDirective else {
throw PureYAML.Parsing.ParseError.duplicateDirective(name: name, line: start.line)
}
state.hasYAMLDirective = true
case "%TAG":
guard parts.count >= 3 else {
return
}
state.tagHandles[parts[1]] = parts[2]
default:
// A YAML processor ignores a directive it does not recognize (the spec calls for a
// warning and otherwise no effect), rather than rejecting the document. The line has
// already been consumed, so there is nothing more to do.
return
}
}
}