PureYAML/Sources/Parsing/Scanner.swift
Mihaela Mihaljevic ce0585f77e test(conformance): track all 402 suite cases; fix 3 regressions
The gap-only manifest stopped guarding a case once it was fixed, so later
work silently regressed previously-fixed cases (caught only by an external
full-suite run, not the gated sweep or full corpus). Replace it with a full
manifest of all 402 official cases (Tests/Fixtures/yaml-test-suite-manifest.yaml),
each carrying its conformant expectation and a passing/gap status, with every
case's files vendored. The gated sweep now asserts every passing case still
passes (the regression guard) and every gap still reproduces, so any regression
fails the build.

The honest gate-enforced count is 329/402 passing (the earlier 348 came from a
more lenient one-time oracle).

Repairs three regressions the new gate revealed: the bucket-6 tab rule
over-rejected tabs in block-scalar content and as separation before a scalar
value (root cause: the batch scanner and the incremental TokenCursor had
divergent line-start loops; unified onto a single scanStep, fixing 96NN/00,
96NN/01, R4YG, Y79Y/001, DK95/00, DK95/03, DK95/07, 6CA3, Q5MG, J3BT); an anchor
on a prior line before an alias key wrongly rejected 26DV; and a tag-only flow
key mis-parsed WZ62. Genuine tab-indentation errors (4EJS, Y79Y/000) still reject.
2026-06-24 00:16:28 +02:00

358 lines
14 KiB
Swift

extension PureYAML.Parsing {
struct Scanner {
init() {}
func scan(_ yaml: String) throws -> [Token] {
var state = State(reader: Reader(yaml))
state.append(.streamStart)
loop: while !state.reader.isAtEnd {
switch try scanStep(&state) {
case .endOfInput:
break loop
case .advanced, .scannedMarker, .scannedToken:
continue
}
}
if state.hasPendingDirective {
throw PureYAML.Parsing.ParseError.directiveWithoutDocument(line: state.reader.mark.line)
}
closeIndentation(&state)
state.append(.streamEnd)
return state.tokens
}
}
}
extension PureYAML.Parsing.Scanner {
struct State {
var reader: PureYAML.Parsing.Reader
var tokens: [PureYAML.Parsing.Token] = []
var indentation: [Int] = [0]
var isAtLineStart = true
var hasDocumentContent = false
var hasDocumentStartMarker = false
var isDocumentClosed = false
/// True once a `%YAML` directive has been scanned for the document that
/// the next `---` will start. A second `%YAML` before that `---` is a
/// duplicate and is rejected; the flag resets at every document boundary.
var sawYAMLDirective = false
/// True once any directive has been scanned in the current directive
/// section. A directive section must be terminated by a `---` document
/// start; reaching a `...` end marker or the end of the stream with a
/// directive still pending is an error.
var hasPendingDirective = false
var flowDepth = 0
/// Mirrors libyaml's `parser->indent`: the 0-based column of the innermost
/// open block collection (sequence or mapping), or -1 at the bare document
/// root. Plain-scalar continuation folding compares a continuation line's
/// indentation against this value, so a root scalar (block indent -1) folds
/// column-0 continuations while a scalar inside a collection only folds lines
/// indented past the collection. Tracked separately from ``indentation`` --
/// which records every line-indentation level -- because libyaml rolls this
/// only on block-collection indicators, not on every indentation increase.
var blockIndent = -1
/// Saved ``blockIndent`` values, mirroring libyaml's `parser->indents` stack,
/// so a dedent restores the enclosing collection's indent.
var blockIndents: [Int] = []
/// Mirrors libyaml's `simple_key_allowed`. In block context a tab may only
/// be consumed as whitespace when a simple key is NOT allowed (i.e. mid-line,
/// after a node has been emitted). It is true at the start of every block
/// line and immediately after a `-`, `?`, or `:` indicator, so a tab in those
/// positions provides block indentation and is rejected. See scanner.c:1948.
var simpleKeyAllowed = true
var flowContexts: [PureYAML.Parsing.ScannerFlowContext] = []
/// A stack of potential simple keys, one entry per open flow collection,
/// mirroring libyaml's `simple_keys`. The slot for the innermost flow level
/// records the most recent position a `:` could promote to a mapping key.
/// Empty outside flow context; block keys do not use this stack.
var flowSimpleKeys: [PureYAML.Parsing.ScannerFlowSimpleKey?] = []
var validImplicitIndentation: Set<Int> = []
var tagHandles = [
"!": "!",
"!!": "tag:yaml.org,2002:",
]
mutating func append(_ kind: PureYAML.Parsing.TokenKind) {
let mark = reader.mark
tokens.append(PureYAML.Parsing.Token(kind: kind, mark: mark, endMark: mark))
markDocumentContentIfNeeded(kind)
}
mutating func append(
_ kind: PureYAML.Parsing.TokenKind,
mark: PureYAML.Parsing.Mark,
endMark: PureYAML.Parsing.Mark,
) {
tokens.append(PureYAML.Parsing.Token(kind: kind, mark: mark, endMark: endMark))
markDocumentContentIfNeeded(kind)
}
mutating func markDocumentContentIfNeeded(_ kind: PureYAML.Parsing.TokenKind) {
switch kind {
case .alias, .anchor, .blockScalarHeader, .scalar, .tag:
// A node (or one of its properties) has appeared on this line, so a
// simple key is no longer allowed and a following tab is ordinary
// content rather than indentation. Mirrors libyaml setting
// simple_key_allowed = 0 after scanning these tokens.
hasDocumentContent = true
simpleKeyAllowed = false
case .comment, .dedent, .documentEnd, .documentStart, .indent, .streamEnd, .streamStart:
return
default:
hasDocumentContent = true
}
}
var isExpectingFlowMappingKey: Bool {
flowContexts.last?.kind == .mapping
&& flowContexts.last?.isExpectingKey == true
}
}
}
extension PureYAML.Parsing.Scanner {
func scanToken(_ state: inout State) throws {
skipSpaceSeparation(&state)
try rejectTabSeparation(&state)
skipSeparation(&state)
guard let character = state.reader.peek(), !isLineBreak(character) else {
return
}
if try scanLineIndicatorToken(character, state: &state) {
return
}
if scanFlowToken(character, state: &state) {
return
}
if try scanQuotedOrBlockScalarToken(character, state: &state) {
return
}
if try scanNodePropertyToken(character, state: &state) {
return
}
scanPlainScalar(&state)
}
func scanLineIndicatorToken(
_ character: Character,
state: inout State,
) throws -> Bool {
switch character {
case "#":
scanComment(&state)
return true
case "-" where isBlockEntry(state.reader):
scanFixedToken(.blockEntry, state: &state)
if state.flowDepth == 0 {
state.simpleKeyAllowed = true
}
return true
case "?" where state.flowDepth > 0 || isIndicatorBoundary(state.reader.peek(offset: 1)):
// In flow context a `?` is always an explicit-key indicator (libyaml's
// fetch_more_tokens checks `flow_level || IS_BLANKZ_AT`); a pending
// simple key is abandoned and no implicit key may follow on this entry.
if state.flowDepth > 0 {
removeFlowSimpleKey(&state)
}
scanFixedToken(.mappingKey, state: &state)
if state.flowDepth == 0 {
state.simpleKeyAllowed = true
} else {
state.simpleKeyAllowed = false
}
return true
case ":" where state.flowDepth > 0 || isAtMappingValueIndicator(state: state):
// In flow context a `:` is always a value indicator regardless of what
// follows it (libyaml), so `"k":v` and `{k}:v` are key/value pairs. A
// pending simple key is spliced into a `mappingKey` before its node.
if state.flowDepth > 0 {
promoteFlowSimpleKey(&state)
}
scanFixedToken(.mappingValue, state: &state)
if state.flowDepth == 0 {
state.simpleKeyAllowed = true
} else {
state.simpleKeyAllowed = false
}
return true
default:
return false
}
}
func scanNodePropertyToken(
_ character: Character,
state: inout State,
) throws -> Bool {
switch character {
case "&":
try scanNamedToken(
kind: PureYAML.Parsing.TokenKind.anchor,
missingNameError: PureYAML.Parsing.ParseError.expectedAnchorName,
state: &state,
)
return true
case "*":
try scanNamedToken(
kind: PureYAML.Parsing.TokenKind.alias,
missingNameError: PureYAML.Parsing.ParseError.expectedAliasName,
state: &state,
)
return true
case "!":
try scanTag(&state)
return true
default:
return false
}
}
func scanQuotedOrBlockScalarToken(
_ character: Character,
state: inout State,
) throws -> Bool {
switch character {
case "\"":
try scanQuotedScalar(.doubleQuoted, state: &state)
return true
case "'":
try scanQuotedScalar(.singleQuoted, state: &state)
return true
case "|":
try scanBlockScalarHeader(.literal, state: &state)
return true
case ">":
try scanBlockScalarHeader(.folded, state: &state)
return true
default:
return false
}
}
func skipSpaceSeparation(_ state: inout State) {
while state.reader.peek() == " " {
state.reader.advance()
}
}
func skipSeparation(_ state: inout State) {
// In-line separation (s-white) is spaces and tabs. `rejectTabSeparation` has
// already rejected any tab that would supply the indentation of a nested block
// node, so a tab still under the reader here is ordinary separation before a
// scalar value or flow node and is consumed like a space (scanner.c IS_BLANK).
while let character = state.reader.peek(), character == " " || character == "\t" {
state.reader.advance()
}
}
func scanFixedToken(
_ kind: PureYAML.Parsing.TokenKind,
state: inout State,
) {
let start = state.reader.mark
// Mirror libyaml rolling the block indent when a block sequence entry (`-`)
// or an explicit `?` mapping key opens a collection at this column. The
// implicit-key case rolls in `appendMappingKeyIfNeeded` at the key column;
// the `:` value indicator does not roll again, because for a simple key the
// mapping indent was already set at the key's (shallower) column and rolling
// to the deeper `:` column would wrongly raise the continuation threshold.
switch kind {
case .blockEntry, .mappingKey:
rollBlockIndent(toColumn: start.column - 1, state: &state)
default:
break
}
state.reader.advance()
state.append(kind, mark: start, endMark: state.reader.mark)
if case .blockEntry = kind, hasNodeOnSameLine(afterCurrentPositionIn: state.reader) {
state.validImplicitIndentation.insert(start.column + 1)
}
if case .mappingValue = kind, state.flowContexts.last?.kind == .mapping {
state.flowContexts[state.flowContexts.count - 1].isExpectingKey = false
}
}
func scanComment(_ state: inout State) {
let start = state.reader.mark
state.reader.advance()
if state.reader.peek() == " " {
state.reader.advance()
}
let value = state.reader.consume { character in
!isLineBreak(character)
}
state.append(.comment(value), mark: start, endMark: state.reader.mark)
}
func scanQuotedScalar(
_ style: PureYAML.Parsing.ScalarStyle,
state: inout State,
) throws {
let start = state.reader.mark
// A quoted scalar may be a flow simple key: record its position before the
// content is consumed so a following `:` can splice a key in front of it.
saveFlowSimpleKey(&state)
let quote: Character = style == .doubleQuoted ? "\"" : "'"
state.reader.advance()
var value = ""
var previousWasEscape = false
while let character = state.reader.peek() {
// A `---` or `...` document marker at column 0 cannot appear inside an
// open quoted scalar: it means the scalar was never closed before a
// document boundary (scanner.c:3058-3070). Reject it as unterminated.
if state.reader.mark.column == 1, isDocumentMarkerHere(state) {
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: start.line)
}
if style == .singleQuoted, character == quote, state.reader.peek(offset: 1) == quote {
value.append(character)
state.reader.advance()
value.append(character)
state.reader.advance()
continue
}
if character == quote, !previousWasEscape {
break
}
value.append(character)
previousWasEscape = character == "\\" && style == .doubleQuoted && !previousWasEscape
state.reader.advance()
if character != "\\" {
previousWasEscape = false
}
}
guard state.reader.peek() == quote else {
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: start.line)
}
state.reader.advance()
appendMappingKeyIfNeeded(start: start, state: &state)
state.append(.scalar(value: value, style: style), mark: start, endMark: state.reader.mark)
}
func scanPlainScalar(_ state: inout State) {
let start = state.reader.mark
// A plain scalar may be a flow simple key: record its position before the
// content is consumed so a following `:` can splice a key in front of it.
saveFlowSimpleKey(&state)
var value = ""
while let character = state.reader.peek() {
if shouldEndPlainScalar(character, state: state, accumulated: value) {
break
}
value.append(character)
state.reader.advance()
}
var trimmed = trimTrailingSpaces(value)
let isMappingKey = appendMappingKeyIfNeeded(start: start, state: &state)
if !isMappingKey {
trimmed = scanPlainScalarContinuations(
after: trimmed,
state: &state,
)
}
state.append(.scalar(value: trimmed, style: .plain), mark: start, endMark: state.reader.mark)
}
}