PureYAML/Sources/Parsing/TokenCursor.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

231 lines
6.2 KiB
Swift

extension PureYAML.Parsing {
private final class TokenScannerStateBox {
var state: Scanner.State
init(state: Scanner.State) {
self.state = state
}
}
struct TokenCursor {
private var tokens: [Token]
private(set) var index: Int
private var previousToken: Token?
private var scanner: Scanner?
private var scannerBox: TokenScannerStateBox?
init(tokens: [Token]) {
self.tokens = tokens.filter { !$0.kind.isComment }
index = 0
previousToken = nil
scanner = nil
scannerBox = nil
}
init(yaml: String, scanner: Scanner = .init()) {
var state = Scanner.State(reader: Reader(yaml))
state.append(.streamStart)
tokens = state.tokens.filter { !$0.kind.isComment }
state.tokens.removeAll(keepingCapacity: true)
index = 0
previousToken = nil
self.scanner = scanner
scannerBox = TokenScannerStateBox(state: state)
}
var current: Token? {
guard tokens.indices.contains(index) else {
return nil
}
return tokens[index]
}
var previous: Token? {
previousToken
}
mutating func peek(offset: Int = 1) throws -> Token? {
try ensureAvailable(offset: offset)
let targetIndex = index + offset
guard tokens.indices.contains(targetIndex) else {
return nil
}
return tokens[targetIndex]
}
mutating func advance() throws -> Token? {
try ensureAvailable(offset: 0)
guard tokens.indices.contains(index) else {
return nil
}
let token = tokens[index]
previousToken = token
index += 1
compactConsumedTokens()
try ensureAvailable(offset: 0)
return token
}
private mutating func ensureAvailable(offset: Int) throws {
while tokens.count <= index + offset, scannerBox != nil {
try scanMoreTokens()
}
}
private mutating func scanMoreTokens() throws {
guard let scanner, let scannerBox else {
return
}
while scannerBox.state.tokens.isEmpty {
guard !scannerBox.state.reader.isAtEnd else {
if scannerBox.state.hasPendingDirective {
throw PureYAML.Parsing.ParseError.directiveWithoutDocument(
line: scannerBox.state.reader.mark.line,
)
}
scanner.closeIndentation(&scannerBox.state)
scannerBox.state.append(.streamEnd)
drainScannedTokens(from: &scannerBox.state)
self.scannerBox = nil
return
}
if scanner.isLineBreak(scannerBox.state.reader.peek()) {
scannerBox.state.reader.advance()
scannerBox.state.isAtLineStart = true
continue
}
if scannerBox.state.isAtLineStart {
try scanner.scanIndentation(&scannerBox.state)
if scannerBox.state.reader.isAtEnd {
continue
}
if scanner.isLineBreak(scannerBox.state.reader.peek()) {
continue
}
if try scanner.scanDirectiveOrDocumentMarker(&scannerBox.state) {
if !scannerBox.state.tokens.isEmpty {
break
}
continue
}
scannerBox.state.isAtLineStart = false
}
try scanner.scanToken(&scannerBox.state)
}
drainScannedTokens(from: &scannerBox.state)
}
private mutating func drainScannedTokens(from state: inout Scanner.State) {
tokens.append(contentsOf: state.tokens.filter { !$0.kind.isComment })
state.tokens.removeAll(keepingCapacity: true)
}
private mutating func compactConsumedTokens() {
guard index > 4096 else {
return
}
tokens.removeFirst(index)
index = 0
}
}
}
extension PureYAML.Parsing.TokenKind {
var isAlias: Bool {
if case .alias = self {
return true
}
return false
}
var isNodeProperty: Bool {
switch self {
case .anchor, .tag:
true
default:
false
}
}
var isBlockEntry: Bool {
if case .blockEntry = self {
return true
}
return false
}
var isComment: Bool {
if case .comment = self {
return true
}
return false
}
var isDedent: Bool {
if case .dedent = self {
return true
}
return false
}
var isDocumentEnd: Bool {
if case .documentEnd = self {
return true
}
return false
}
var isDocumentStart: Bool {
if case .documentStart = self {
return true
}
return false
}
var isFlowTerminator: Bool {
switch self {
case .flowEntry, .flowMappingEnd, .flowSequenceEnd:
true
default:
false
}
}
var isIndent: Bool {
if case .indent = self {
return true
}
return false
}
var isMappingKey: Bool {
if case .mappingKey = self {
return true
}
return false
}
var isMappingValue: Bool {
if case .mappingValue = self {
return true
}
return false
}
var isStreamEnd: Bool {
if case .streamEnd = self {
return true
}
return false
}
var isTerminator: Bool {
isDedent || isDocumentEnd || isDocumentStart || isFlowTerminator || isStreamEnd
}
}