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.
324 lines
17 KiB
Swift
324 lines
17 KiB
Swift
extension PureYAML.Parsing.LibYAML.Scanner {
|
|
/// Which step of `runPlainScalarLoop`'s outer iteration a suspend happened
|
|
/// at. Resuming must land back on the step that was actually interrupted,
|
|
/// not on whatever step normally follows it, and in particular must never
|
|
/// redo a decision (document marker, comment, or "does a blank/break run
|
|
/// follow") that a *previous, already-passed* check already answered:
|
|
/// `.start`'s document-marker/`#` checks and `.afterContent`'s blank/break
|
|
/// guard are only meant to run once, at a genuine entry point, not at an
|
|
/// arbitrary position inside an already-in-progress content or break run.
|
|
/// A first version of this type resumed a dry `consumePlainContent` via
|
|
/// `.start` on the (wrong) assumption that redoing its checks was harmless;
|
|
/// it is not; if the resume position happens to land on a literal `#`
|
|
/// that is legitimate mid-scalar content (e.g. a value containing every
|
|
/// printable character), `.start`'s comment check wrongly ends the scalar
|
|
/// right there. `consumePlainContent` and `consumePlainBreaks` each get
|
|
/// their own dedicated "continue this same call, no pre-check" case
|
|
/// (`.consumingContent`, `.consumingBreaks`) for exactly this reason.
|
|
enum PlainScalarPhase: Equatable {
|
|
/// Check for a document marker or comment, then consume content.
|
|
case start
|
|
/// Inside consuming an already-started content run (the document
|
|
/// marker/comment checks that gated entry into it do not apply here).
|
|
case consumingContent
|
|
/// Content just finished; decide whether a blank/break run follows.
|
|
case afterContent
|
|
/// Inside consuming an already-confirmed blank/break run.
|
|
case consumingBreaks
|
|
/// A blank/break run just finished; check whether the scalar dedents.
|
|
case afterBreaks
|
|
}
|
|
|
|
/// Thrown by `runPlainScalarLoop` when accumulating a plain scalar's content
|
|
/// runs dry (`Reader.ranDry`) while `!Reader.isFinal`. Carries everything
|
|
/// `resumePlainScalar(_:)` needs to continue from exactly this point once more
|
|
/// input is appended, rather than rescanning the scalar's content from its
|
|
/// start. `scanStep()` catches this specifically and stores it on
|
|
/// `Scanner.suspendedPlainScalar`, distinct from a generic dry-run catch: no
|
|
/// checkpoint restore happens for this error, since the whole point is to keep
|
|
/// the reader position and accumulated content rather than discard them.
|
|
struct PlainScalarSuspended: Swift.Error {
|
|
var state: PlainState
|
|
var indent: Int
|
|
var startMark: PureYAML.Parsing.Mark
|
|
var phase: PlainScalarPhase
|
|
}
|
|
|
|
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
|
|
|
/// The folding state carried through `scan_plain_scalar`: the accumulated
|
|
/// value plus the pending leading break, trailing breaks, and whitespace that
|
|
/// are folded in before the next content character. Internal rather than
|
|
/// private: `PlainScalarSuspended` carries one across a `scanStep()` suspend
|
|
/// point, so `Scanner`'s own file needs to name the type for its stored
|
|
/// `suspendedPlainScalar` property.
|
|
struct PlainState {
|
|
var string = ""
|
|
var leadingBreak = ""
|
|
var trailingBreaks = ""
|
|
var whitespaces = ""
|
|
var leadingBlanks = false
|
|
var endMark: PureYAML.Parsing.Mark
|
|
}
|
|
|
|
/// Port of `yaml_parser_scan_plain_scalar`. Consumes a plain scalar, folding
|
|
/// line breaks: a single break between non-empty lines becomes a space, blank
|
|
/// lines become literal newlines, and leading/trailing whitespace on folded
|
|
/// lines is dropped. Ends at a `: ` value indicator, a comment, a less-indented
|
|
/// line (block context), a flow indicator (flow context), or a document marker.
|
|
mutating func scanPlainScalar() throws -> PureYAML.Parsing.LibYAML.Token {
|
|
let indent = indentEngine.indent + 1
|
|
let startMark = reader.mark
|
|
var state = PlainState(endMark: reader.mark)
|
|
try runPlainScalarLoop(&state, indent: indent, startMark: startMark)
|
|
return finishPlainScalar(state, startMark: startMark)
|
|
}
|
|
|
|
/// Resumes a plain scalar suspended by a previous `PlainScalarSuspended`,
|
|
/// continuing from its saved state, phase, and reader position rather than
|
|
/// rescanning the content already accumulated before the suspend.
|
|
///
|
|
/// Takes `state` `inout` rather than the whole `PlainScalarSuspended`, on
|
|
/// purpose: the caller (`scanStep()`) must disalias `state` from the
|
|
/// suspended value it came from *before* this call, since a `let
|
|
/// suspended = ...; resumePlainScalar(suspended)` call would keep the
|
|
/// caller's own `suspended.state` reference to the same string buffer
|
|
/// alive for this whole call's duration regardless of anything done
|
|
/// inside this function's own body (a value parameter is borrowed for
|
|
/// the call, not owned, so a local disalias inside the callee cannot
|
|
/// remove a reference the caller itself is still holding). With `state`
|
|
/// passed by direct reference and no wrapping struct crossing this call
|
|
/// boundary at all, there is exactly one owner of its string buffer
|
|
/// throughout, so appending inside the loop below never triggers a
|
|
/// spurious copy-on-write copy of the whole accumulated scalar. See
|
|
/// `scanStep()`'s resume branch for where the disalias happens.
|
|
mutating func resumePlainScalar(
|
|
_ state: inout PlainState,
|
|
indent: Int,
|
|
startMark: PureYAML.Parsing.Mark,
|
|
phase: PlainScalarPhase,
|
|
) throws -> PureYAML.Parsing.LibYAML.Token {
|
|
try runPlainScalarLoop(&state, indent: indent, startMark: startMark, from: phase)
|
|
return finishPlainScalar(state, startMark: startMark)
|
|
}
|
|
|
|
/// The scan loop shared by a fresh attempt and a resume, restructured as an
|
|
/// explicit `PlainScalarPhase` state machine (see its own doc comment for
|
|
/// why) with a `suspendIfDry` check after every step that peeked. Checking
|
|
/// only once, after the whole loop exits, is not enough: a dry read
|
|
/// (`reader.ranDry`, see `Reader.peek(_:)`) inside a lookahead
|
|
/// (`documentMarkerAhead()`, `consumePlainContent`'s `:`-followed-by-blank
|
|
/// check, `consumePlainBreaks`'s `blankLineAhead()` guard) reads as "blank or
|
|
/// end" (`CharacterClass.isBlankZ`/`isBreakZ` treat a missing scalar as
|
|
/// true), not as "not enough data yet", so the *next* step can reach a
|
|
/// wrong-but-plausible decision (e.g. `documentMarkerAhead` wrongly says
|
|
/// "not a marker", so `consumePlainContent` appends `---` as scalar content)
|
|
/// before a single end-of-loop check would ever see the taint that caused
|
|
/// it. Checking right after each peeking step, before its result is used for
|
|
/// anything further, catches the taint before it can influence another
|
|
/// decision. Batch parsing (`isFinal: true`) is unaffected: every check can
|
|
/// only throw when `!reader.isFinal`, which batch callers never set, so the
|
|
/// loop's own logic is otherwise identical to the original.
|
|
///
|
|
/// `state` and the reader position are always resumable at a suspend point:
|
|
/// nothing here calls `reader.skip()` on a dry `nil`, so accumulated content
|
|
/// and position both stop exactly at the last successfully read scalar.
|
|
private mutating func runPlainScalarLoop(
|
|
_ state: inout PlainState,
|
|
indent: Int,
|
|
startMark: PureYAML.Parsing.Mark,
|
|
from initialPhase: PlainScalarPhase = .start,
|
|
) throws {
|
|
var phase = initialPhase
|
|
while true {
|
|
switch phase {
|
|
case .start:
|
|
let atDocumentMarker = reader.column == 0 && documentMarkerAhead()
|
|
// A dry lookahead inside `documentMarkerAhead()` must resume by
|
|
// redoing this whole step, not by trusting `atDocumentMarker`
|
|
// (which `isBlankZ`'s nil-is-true bias could have gotten wrong)
|
|
// and moving on: hence `phase: .start` here, not `.consumingContent`.
|
|
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .start)
|
|
if atDocumentMarker {
|
|
return
|
|
}
|
|
if reader.peek() == "#" {
|
|
return
|
|
}
|
|
try consumePlainContent(&state, startMark: startMark)
|
|
// consumePlainContent stopping is not yet known to be a real
|
|
// answer (terminator found) versus a dry cutoff: check before
|
|
// trusting it enough to move to `.afterContent`'s guard. On dry,
|
|
// resume via `.consumingContent`, not `.start`: the document-marker
|
|
// and `#` checks above only apply at a genuine entry point, and
|
|
// the reader position where a content run was interrupted can
|
|
// land on a literal `#` or `-` that is legitimate content, not a
|
|
// fresh comment or marker.
|
|
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
|
|
phase = .afterContent
|
|
case .consumingContent:
|
|
try consumePlainContent(&state, startMark: startMark)
|
|
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
|
|
phase = .afterContent
|
|
case .afterContent:
|
|
// Reached only once consumePlainContent is known to have stopped
|
|
// for a real reason, so this peek cannot itself be dry: it
|
|
// re-reads the exact position consumePlainContent's own last
|
|
// (already non-dry) peek confirmed, and nothing advanced between.
|
|
guard Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) else {
|
|
return
|
|
}
|
|
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
|
|
// Same reasoning as consumePlainContent above, but resuming here
|
|
// must not redo `.afterContent`'s guard against new data: that
|
|
// guard gates *entry* into a blank/break run, and re-asking it
|
|
// after the run is already underway would wrongly treat "the run
|
|
// continues" as "the run never started" (or vice versa) instead
|
|
// of just continuing the same run. `.consumingBreaks` re-enters
|
|
// consumePlainBreaks directly.
|
|
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
|
phase = .afterBreaks
|
|
case .consumingBreaks:
|
|
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
|
|
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
|
phase = .afterBreaks
|
|
case .afterBreaks:
|
|
// `reader.column` is a plain stored field, never read via
|
|
// `peek(_:)`, so this check cannot itself observe a dry read; by
|
|
// construction every path reaching this case already confirmed
|
|
// `!ranDry` before transitioning here.
|
|
if flowLevel == 0, reader.column < indent {
|
|
return
|
|
}
|
|
phase = .start
|
|
}
|
|
}
|
|
}
|
|
|
|
private func suspendIfDry(
|
|
_ state: PlainState,
|
|
indent: Int,
|
|
startMark: PureYAML.Parsing.Mark,
|
|
phase: PlainScalarPhase,
|
|
) throws {
|
|
if !reader.isFinal, reader.ranDry {
|
|
throw PlainScalarSuspended(state: state, indent: indent, startMark: startMark, phase: phase)
|
|
}
|
|
}
|
|
|
|
private mutating func finishPlainScalar(
|
|
_ state: PlainState,
|
|
startMark: PureYAML.Parsing.Mark,
|
|
) -> PureYAML.Parsing.LibYAML.Token {
|
|
if state.leadingBlanks {
|
|
simpleKeyAllowed = true
|
|
}
|
|
return PureYAML.Parsing.LibYAML.Token(
|
|
type: .scalar(value: state.string, style: .plain),
|
|
startMark: startMark,
|
|
endMark: state.endMark,
|
|
)
|
|
}
|
|
|
|
/// Consume the run of non-blank characters, folding any pending breaks or
|
|
/// whitespace ahead of each one.
|
|
private mutating func consumePlainContent(_ state: inout PlainState, startMark _: PureYAML.Parsing.Mark) throws {
|
|
while let character = reader.peek(), !Classify.isBlankZ(character) {
|
|
if character == ":", Classify.isBlankZ(reader.peek(1)) {
|
|
break
|
|
}
|
|
if flowLevel > 0, character == ":", Classify.isFlowIndicator(reader.peek(1)) {
|
|
// A `:` before a flow indicator ends the key; the value is empty.
|
|
// libyaml rejects this as an unexpected ':', but it is a valid
|
|
// value indicator in flow context.
|
|
break
|
|
}
|
|
if flowLevel > 0, Classify.isFlowIndicator(character) {
|
|
break
|
|
}
|
|
foldPending(&state)
|
|
state.string.unicodeScalars.append(character)
|
|
reader.skip()
|
|
state.endMark = reader.mark
|
|
}
|
|
}
|
|
|
|
/// Fold the pending leading break, trailing breaks, and whitespace into the
|
|
/// accumulated value (the join logic from libyaml's inner loop).
|
|
private func foldPending(_ state: inout PlainState) {
|
|
guard state.leadingBlanks || !state.whitespaces.isEmpty else {
|
|
return
|
|
}
|
|
guard state.leadingBlanks else {
|
|
state.string += state.whitespaces
|
|
state.whitespaces = ""
|
|
return
|
|
}
|
|
if state.leadingBreak.first == "\n" {
|
|
if state.trailingBreaks.isEmpty {
|
|
state.string += " "
|
|
} else {
|
|
state.string += state.trailingBreaks
|
|
state.trailingBreaks = ""
|
|
}
|
|
state.leadingBreak = ""
|
|
} else {
|
|
state.string += state.leadingBreak
|
|
state.string += state.trailingBreaks
|
|
state.leadingBreak = ""
|
|
state.trailingBreaks = ""
|
|
}
|
|
state.leadingBlanks = false
|
|
}
|
|
|
|
/// Consume the run of blanks and line breaks between content, recording them as
|
|
/// pending whitespace or folded breaks.
|
|
private mutating func consumePlainBreaks(_ state: inout PlainState, indent: Int, startMark: PureYAML.Parsing.Mark) throws {
|
|
while Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) {
|
|
if Classify.isBlank(reader.peek()) {
|
|
if state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()) {
|
|
// `blankLineAhead()` is its own multi-character lookahead (not
|
|
// just the single already-confirmed-real `peek()` above), so it
|
|
// can independently run dry: check immediately, before the
|
|
// tab-violation decision below trusts its answer. Unchecked,
|
|
// `isBreakZ`'s nil-is-true bias makes an incomplete lookahead
|
|
// read as "blank line", suppressing a real violation until more
|
|
// data arrives proves otherwise.
|
|
let onBlankLine = blankLineAhead()
|
|
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
|
if !onBlankLine {
|
|
// A tab below the indentation is illegal only when it indents
|
|
// real content; on an otherwise-blank line it is just a blank
|
|
// line the scalar folds over. libyaml rejects it either way.
|
|
throw scannerError("found a tab character that violates indentation", at: startMark)
|
|
}
|
|
}
|
|
if !state.leadingBlanks, let blank = reader.peek() {
|
|
state.whitespaces.unicodeScalars.append(blank)
|
|
}
|
|
reader.skip()
|
|
} else if !state.leadingBlanks {
|
|
state.whitespaces = ""
|
|
reader.skipLine()
|
|
state.leadingBreak = "\n"
|
|
state.leadingBlanks = true
|
|
} else {
|
|
reader.skipLine()
|
|
state.trailingBreaks += "\n"
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scannerError(_ problem: String, at mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
|
|
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a plain scalar", problem: problem, mark: mark)
|
|
}
|
|
|
|
private mutating func documentMarkerAhead() -> Bool {
|
|
let first = reader.peek()
|
|
guard first == "-" || first == "." else {
|
|
return false
|
|
}
|
|
return reader.peek(1) == first && reader.peek(2) == first && Classify.isBlankZ(reader.peek(3))
|
|
}
|
|
}
|