PureYAML/Sources/Parsing/LibYAML.Scanner.Directive.swift
Mihaela Mihaljevic b5129d71ee fix(parsing): reject misplaced directives, refresh suite manifest
Directives apply to the document that follows, so the previous document
must be closed with an explicit '...' end marker before them. After an
implicit end, a '%YAML'/'%TAG'/reserved directive is now rejected instead
of being silently accepted. Also reject a '%YAML' version trailed directly
by '#' with no separating space ('%YAML 1.1#...'), since a comment needs
leading separation; libyaml accepts both.

This restores reject-side conformance the suite manifest recorded as
passing but the libyaml port had dropped (9HCY, EB22, MUS6/00, MUS6/01,
RHX7), and updates the two PureYAML gap-error fixtures whose rejection now
fires earlier and more precisely at the misplaced directive.

Refresh yaml-test-suite-manifest.yaml to version 3: whole-suite
conformance is 384/402 (up from the recorded 331); 53 cases still marked
as gaps now pass, none regress.
2026-06-24 19:51:39 +02:00

154 lines
6.9 KiB
Swift

extension PureYAML.Parsing.LibYAML.Scanner {
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
private typealias Token = PureYAML.Parsing.LibYAML.Token
private typealias Mark = PureYAML.Parsing.Mark
/// The longest version number libyaml accepts (`MAX_NUMBER_LENGTH`).
private var maxVersionNumberLength: Int {
9
}
/// Port of `yaml_parser_fetch_directive`: reset the indentation and simple keys,
/// then scan a `%YAML` or `%TAG` directive token.
mutating func fetchDirective() throws {
indentEngine.unrollIndent(column: -1, mark: reader.mark)
try removeSimpleKey()
simpleKeyAllowed = false
let token = try scanDirective()
indentEngine.enqueue(token)
}
/// Port of `yaml_parser_scan_directive`, extended to YAML 1.2 §6.8.1: a `%YAML`
/// or `%TAG` directive produces its token; any other (reserved) directive is
/// ignored with a warning, producing a `reservedDirective` token whose content
/// the parser drops, rather than libyaml's hard rejection. The reserved token
/// still marks the document as a directives document, so it must be followed by
/// a `---`.
private mutating func scanDirective() throws -> Token {
let startMark = reader.mark
reader.skip()
let name = try scanDirectiveName(startMark: startMark)
if name == "YAML" {
let version = try scanVersionDirectiveValue(startMark: startMark)
let token = Token(type: .versionDirective(major: version.major, minor: version.minor), startMark: startMark, endMark: reader.mark)
try consumeDirectiveTail(startMark: startMark)
return token
}
if name == "TAG" {
let value = try scanTagDirectiveValue(startMark: startMark)
let token = Token(type: .tagDirective(handle: value.handle, prefix: value.prefix), startMark: startMark, endMark: reader.mark)
try consumeDirectiveTail(startMark: startMark)
return token
}
skipReservedDirectiveLine()
return Token(type: .reservedDirective, startMark: startMark, endMark: reader.mark)
}
/// Consume a reserved directive's parameters through the end of the line; the
/// whole line is ignored per YAML 1.2 §6.8.1.
private mutating func skipReservedDirectiveLine() {
while !Classify.isBreakZ(reader.peek()) {
reader.skip()
}
if Classify.isBreak(reader.peek()) {
reader.skipLine()
}
}
/// Eat trailing blanks and an optional comment, require a line break, eat it.
private mutating func consumeDirectiveTail(startMark: Mark) throws {
while Classify.isBlank(reader.peek()) {
reader.skip()
}
if reader.peek() == "#" {
while !Classify.isBreakZ(reader.peek()) {
reader.skip()
}
}
guard Classify.isBreakZ(reader.peek()) else {
throw directiveError("while scanning a directive", "did not find expected comment or line break", startMark)
}
if Classify.isBreak(reader.peek()) {
reader.skipLine()
}
}
/// Port of `yaml_parser_scan_directive_name`: the alphanumeric name after `%`,
/// which must be non-empty and followed by a blank or line break.
private mutating func scanDirectiveName(startMark: Mark) throws -> String {
var name = ""
while let character = reader.peek(), Classify.isAlpha(character) {
name.unicodeScalars.append(character)
reader.skip()
}
guard !name.isEmpty else {
throw directiveError("while scanning a directive", "could not find expected directive name", startMark)
}
guard Classify.isBlankZ(reader.peek()) else {
throw directiveError("while scanning a directive", "found unexpected non-alphabetical character", startMark)
}
return name
}
/// Port of `yaml_parser_scan_version_directive_value`: `major.minor`.
private mutating func scanVersionDirectiveValue(startMark: Mark) throws -> (major: Int, minor: Int) {
while Classify.isBlank(reader.peek()) {
reader.skip()
}
let major = try scanVersionDirectiveNumber(startMark: startMark)
guard reader.peek() == "." else {
throw directiveError("while scanning a %YAML directive", "did not find expected digit or '.' character", startMark)
}
reader.skip()
let minor = try scanVersionDirectiveNumber(startMark: startMark)
// The version must be followed by separation; a comment needs a space
// before its `#`, so `%YAML 1.1#...` (no space) is malformed. libyaml
// accepts it, treating the `#` as an adjacent comment.
guard Classify.isBlankZ(reader.peek()) else {
throw directiveError("while scanning a %YAML directive", "did not find expected digit or '.' character", startMark)
}
return (major, minor)
}
/// Port of `yaml_parser_scan_version_directive_number`: a bounded run of digits.
private mutating func scanVersionDirectiveNumber(startMark: Mark) throws -> Int {
var value = 0
var length = 0
while let character = reader.peek(), Classify.isDigit(character) {
length += 1
guard length <= maxVersionNumberLength else {
throw directiveError("while scanning a %YAML directive", "found extremely long version number", startMark)
}
value = value * 10 + Int(character.value - Unicode.Scalar("0").value)
reader.skip()
}
guard length > 0 else {
throw directiveError("while scanning a %YAML directive", "did not find expected version number", startMark)
}
return value
}
/// Port of `yaml_parser_scan_tag_directive_value`: `!handle! prefix`.
private mutating func scanTagDirectiveValue(startMark: Mark) throws -> (handle: String, prefix: String) {
while Classify.isBlank(reader.peek()) {
reader.skip()
}
let handle = try scanTagHandle(directive: true, startMark: startMark)
guard Classify.isBlank(reader.peek()) else {
throw directiveError("while scanning a %TAG directive", "did not find expected whitespace", startMark)
}
while Classify.isBlank(reader.peek()) {
reader.skip()
}
let prefix = try scanTagURI(uriChar: true, directive: true, head: "", startMark: startMark)
guard Classify.isBlankZ(reader.peek()) else {
throw directiveError("while scanning a %TAG directive", "did not find expected whitespace or line break", startMark)
}
return (handle, prefix)
}
private func directiveError(_ context: String, _ problem: String, _ mark: Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
PureYAML.Parsing.LibYAML.ScannerError(context: context, problem: problem, mark: mark)
}
}