PureYAML/Sources/Parsing/Scanner.Directives.swift
Mihaela Mihaljevic cd05976fac feat(parsing): handle directives and document-end boundaries
Three root causes: (1) a '%' begins a directive only in directive position (before document content, outside flow); everywhere else it is plain content, fixing misfires inside plain scalars, flow mappings, and block scalars. (2) %YAML accepts any 1.x (a higher minor is treated as 1.2 without error), rejects major != 1 and duplicate %YAML, and requires a following '---'; reserved and %TAG directives are accepted and ignored. A directive not followed by a document start is rejected with the new directiveWithoutDocument error. (3) An explicit '...' end marker closes the current document so following content begins a fresh document in parseStream; single-document parse still rejects multi-document input at compose time.

Tests/UnsupportedYAMLGapsFixtures.swift, StreamParsingTests, ScannerTests, and ParsingErrorTests were updated in lockstep to assert the now-correct behavior (percent-after-content is plain text; '...' then content is two documents; %YAML 1.3 is valid while 2.0 and duplicate %YAML are rejected); no test was weakened and multi-document rejection for single-document parse is still covered.

Flips 15 official YAML test suite cases (295/402); SF5V, 9MMA, B63P, H7TQ now correctly rejected. 6LVF and W4TN are reclassified to json-mismatch (blocked by other scalar gaps). Updates the manifest, fixtures, counts, and roadmap.
2026-06-23 21:31:58 +02:00

162 lines
6.0 KiB
Swift

extension PureYAML.Parsing.Scanner {
func scanDirectiveOrDocumentMarker(_ state: inout State) throws -> Bool {
guard state.indentation.last == 0, state.flowDepth == 0 else {
return false
}
if state.reader.peek() == "%" {
// `%` introduces a directive only in directive position: before any
// document content and before the `---` that starts a document. After
// content, after `---`, or anywhere inside a node, `%` is plain text,
// so defer to ordinary token scanning by returning false. This mirrors
// the C scanner, which only enters `yaml_parser_scan_directive` from
// the start-of-line directive context, never inside a node or scalar.
guard !state.hasDocumentContent, !state.hasDocumentStartMarker else {
return false
}
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)) {
// A `...` end marker terminates the directive section it follows just
// as a `---` would, except that no document was started, so a pending
// directive here was never attached to a document and is rejected.
if state.hasPendingDirective {
throw PureYAML.Parsing.ParseError.directiveWithoutDocument(line: state.reader.mark.line)
}
scanDocumentMarker(.documentEnd, state: &state)
resetForDocumentEnd(&state)
return true
}
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)
skipLine(&state)
}
func resetForDocumentStart(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = true
state.isDocumentClosed = false
state.sawYAMLDirective = false
state.hasPendingDirective = false
}
func resetForDocumentEnd(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = false
state.isDocumentClosed = true
state.sawYAMLDirective = false
state.hasPendingDirective = false
}
func scanDirective(_ state: inout State) throws {
let start = state.reader.mark
let line = state.reader.consume { character in
!isLineBreak(character)
}
let fields = directiveFields(line)
guard let name = fields.first else {
return
}
switch name {
case "%YAML":
try scanYAMLDirective(fields: fields, start: start, state: &state)
case "%TAG":
scanTagDirective(fields: fields, state: &state)
default:
// Reserved or unknown directives are ignored with a warning per the
// spec; PureYAML accepts and discards them rather than erroring.
break
}
state.hasPendingDirective = true
}
/// Splits a directive line into its `%name` and parameter fields, dropping
/// any trailing comment. Fields are separated by spaces or tabs, matching the
/// C scanner's blank handling around directive values.
private func directiveFields(_ line: String) -> [String] {
var fields: [String] = []
var current = ""
for character in line {
// A `#` at a field boundary (i.e. preceded by whitespace or the line
// start) begins a trailing comment that ends the directive's fields.
if character == "#", current.isEmpty {
break
}
if character == " " || character == "\t" {
if !current.isEmpty {
fields.append(current)
current = ""
}
continue
}
current.append(character)
}
if !current.isEmpty {
fields.append(current)
}
return fields
}
private func scanYAMLDirective(
fields: [String],
start: PureYAML.Parsing.Mark,
state: inout State,
) throws {
// A `%YAML` directive carries exactly one version field of the form
// `major.minor`. A duplicate within one document, extra trailing fields,
// or an unsupported major version is an error; a higher 1.x minor is
// accepted (treated as 1.2 with a warning) so future minors keep parsing.
guard !state.sawYAMLDirective else {
throw PureYAML.Parsing.ParseError.incompatibleYAMLDirective(line: start.line)
}
guard fields.count == 2, isSupportedYAMLVersion(fields[1]) else {
throw PureYAML.Parsing.ParseError.incompatibleYAMLDirective(line: start.line)
}
state.sawYAMLDirective = true
}
private func isSupportedYAMLVersion(_ version: String) -> Bool {
let components = version.split(separator: ".", omittingEmptySubsequences: false)
guard components.count == 2 else {
return false
}
let major = components[0]
let minor = components[1]
guard !major.isEmpty, !minor.isEmpty else {
return false
}
guard major.allSatisfy(\.isNumber), minor.allSatisfy(\.isNumber) else {
return false
}
return major == "1"
}
private func scanTagDirective(
fields: [String],
state: inout State,
) {
guard fields.count >= 3 else {
return
}
state.tagHandles[fields[1]] = fields[2]
}
}