PureYAML/Sources/Parsing/LibYAML.Scanner.swift
Mihaela Mihaljevic 41ca600d93 feat(parsing): persist mid-token suspension and expose it as a public streaming API
Plain scalars now suspend and resume mid-token instead of rolling back to a
checkpoint and rescanning from the token's start on every chunk boundary
inside them: scanPlainScalar's loop is an explicit PlainScalarPhase state
machine that throws PlainScalarSuspended the moment a lookahead runs dry, and
scanStep() resumes it directly from Scanner.suspendedPlainScalar. Getting
this right took four rounds of bugs, each caught by the existing whole-suite
streaming differential test: checking ranDry only once at the loop's end
instead of after every peek-dependent decision, resuming a dry-interrupted
content or break run through a pre-check meant only for a fresh entry point,
trusting dispatchToken's own dry-fooled document-marker check, and a nested
blankLineAhead() lookahead independently going dry before an outer check
could catch it.

A synthetic long-scalar test (needed since the real-world benchmark fixture's
scalars are too short to exercise this) then caught a fifth, genuine bug: the
first working resumePlainScalar took the whole suspended value by parameter,
which kept a reference to its accumulated string alive for the entire resume
call and defeated copy-on-write, copying the whole scalar on every resume.
That is O(N^2) in the scalar's length, and measured 29x slower than not
suspending at all at small chunk sizes. Passing state inout directly, with
the alias broken at the call site before the call, fixed it to 3-4x, verified
genuinely linear (not just a smaller quadratic constant) by a permanent test
scaling scalar length 16x at a fixed chunk size.

Also exposes this scanner as public API for the first time: scanStep() and
everything around it were internal-only, so no consumer outside this module,
including the separate PureYAMLStreaming package that actually wants a
resumable token scanner, could call it. PureYAML.Streaming.Scanner wraps
scanStep() directly with no independent logic, translating its tokens into
public Streaming.Token/Kind/ScalarStyle types that don't leak the libyaml
port's internal naming. Proven separately from the internal scanner with its
own differential test, since a wrong case mapping in the translation layer
wouldn't be caught by a proof that never looks at the public types.

Stops short of rewiring PureYAMLStreaming's own document-boundary scanner
onto this; that is separate, cross-repo work with its own build and CI cycle.
2026-07-10 09:10:56 +02:00

144 lines
6.9 KiB
Swift

extension PureYAML.Parsing.LibYAML {
/// Aggregate scanner state, mirroring the fields of `yaml_parser_t` that the
/// ported functions read and write. It is built up as the port proceeds;
/// currently it holds the indentation engine and the simple-key mechanism.
/// Column values follow libyaml's 0-based convention internally; conversion to
/// the package's 1-based `Mark` happens at token emission during integration.
struct Scanner {
/// The input buffer and position (`parser->buffer` / `parser->mark`).
var reader: Reader
/// The indentation stack and `roll_indent` / `unroll_indent` (also the
/// owner of `flow_level` and the token queue).
var indentEngine = IndentEngine()
/// `parser->simple_keys`. Always holds at least the block-context level.
var simpleKeys: [SimpleKey] = [.empty]
/// `parser->simple_key_allowed`. True at stream start.
var simpleKeyAllowed = true
/// Whether a tab appeared in the indentation before the pending token. A
/// tab is valid in-line separation but never indentation, so a block
/// indicator or implicit mapping key reached through one is an error.
var tabbedSeparation = false
/// Whether reaching the pending token crossed at least one line break. In
/// flow context a continuation line must be indented past the parent block,
/// so a token on a new line at or below that indent is an error.
var crossedLineBreak = false
/// Whether a tab appeared in the leading whitespace of a flow continuation
/// line. A tab is never indentation, so flow content reached through one is
/// an error even though the tab inflates the column.
var flowLeadingTab = false
/// Whether the most recent `:` value indicator sat on the same line as its
/// key (an inline implicit-key value). Such a value may not open a block
/// sequence on that line (`key: - a`), whereas an explicit key whose `:` is
/// on a later line may (`? k`\n`: - a`).
var lastValueWasInline = false
/// For each open flow level, whether it is a sequence (`[`) rather than a
/// mapping (`{`). A flow-sequence single-pair implicit key is single-line
/// (`c-s-implicit-json-key`), so its `:` may not cross a line break, unlike
/// a flow-mapping adjacent value (`{ "a"`\n`: b }` is allowed).
var flowIsSequence: [Bool] = []
/// `parser->mark`, kept in sync with the reader at each fetch step.
var mark: PureYAML.Parsing.Mark = .start
/// `parser->stream_start_produced`.
var streamStartProduced = false
/// `parser->stream_end_produced`.
var streamEndProduced = false
/// A plain scalar fetch that ran dry mid-content (`scanStep()` only, batch
/// scanning never sets this): resumed from here on the next `scanStep()`
/// call instead of restarting the scalar from its first scalar.
var suspendedPlainScalar: PlainScalarSuspended?
init(_ input: String = "", isFinal: Bool = true) {
reader = Reader(input, isFinal: isFinal)
}
/// `parser->flow_level`, kept on the indent engine.
var flowLevel: Int {
indentEngine.flowLevel
}
/// Port of `yaml_parser_stale_simple_keys`. A potential simple key is
/// invalid once it is on an earlier line or more than 1024 characters back
/// (a simple key is single-line and bounded); a required one going stale is
/// an error.
mutating func staleSimpleKeys() throws {
for index in simpleKeys.indices {
let key = simpleKeys[index]
guard key.possible else {
continue
}
// A block-context key (index 0) is single-line. A flow-context key
// (index >= 1) may be separated from its ':' by line breaks under
// YAML 1.2, so a new line alone does not invalidate it; both remain
// bounded to 1024 characters. libyaml invalidates every key on a new
// line, which wrongly rejects multi-line flow keys.
let tooFarBack = key.mark.index + 1024 < mark.index
let stale = index == 0 ? (key.mark.line < mark.line || tooFarBack) : tooFarBack
guard stale else {
continue
}
if key.required {
throw ScannerError(
context: "while scanning a simple key",
problem: "could not find expected ':'",
mark: key.mark,
)
}
simpleKeys[index].possible = false
}
}
/// Port of `yaml_parser_save_simple_key`. Record the current position as a
/// potential simple key for this flow level when one is allowed. A key is
/// required (a `:` must follow) when it sits exactly at the block indent.
mutating func saveSimpleKey() throws {
let required = flowLevel == 0 && indentEngine.indent == mark.column
guard simpleKeyAllowed else {
return
}
let key = SimpleKey(
possible: true,
required: required,
tokenNumber: indentEngine.tokensParsed + indentEngine.tokens.count,
mark: mark,
tabbed: tabbedSeparation,
)
try removeSimpleKey()
simpleKeys[simpleKeys.count - 1] = key
}
/// Port of `yaml_parser_remove_simple_key`. Drop the potential key at the
/// current flow level; removing a required key is an error.
mutating func removeSimpleKey() throws {
let top = simpleKeys.count - 1
if simpleKeys[top].possible, simpleKeys[top].required {
throw ScannerError(
context: "while scanning a simple key",
problem: "could not find expected ':'",
mark: simpleKeys[top].mark,
)
}
simpleKeys[top].possible = false
}
/// Port of `yaml_parser_increase_flow_level`. Push an empty simple key for
/// the new flow level, recording whether the level is a sequence.
mutating func increaseFlowLevel(isSequence: Bool) {
simpleKeys.append(.empty)
flowIsSequence.append(isSequence)
indentEngine.flowLevel += 1
}
/// Port of `yaml_parser_decrease_flow_level`. Pop the flow level's simple
/// key.
mutating func decreaseFlowLevel() {
if indentEngine.flowLevel > 0 {
indentEngine.flowLevel -= 1
simpleKeys.removeLast()
if !flowIsSequence.isEmpty {
flowIsSequence.removeLast()
}
}
}
}
}