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.
118 lines
5.0 KiB
Swift
118 lines
5.0 KiB
Swift
public extension PureYAML.Streaming {
|
|
/// A resumable, chunked YAML token scanner: the public counterpart to the
|
|
/// internal libyaml-ported scanner's `scanStep()`, which this wraps
|
|
/// directly with no independent logic of its own. Proven to match batch
|
|
/// scanning byte-for-byte across the whole yaml-test-suite corpus and at
|
|
/// several chunk sizes (`StreamingScannerDifferentialTests` in the test
|
|
/// suite exercises the identical internal `scanStep()` this type calls).
|
|
struct Scanner: Sendable {
|
|
private var scanner: PureYAML.Parsing.LibYAML.Scanner
|
|
|
|
/// Creates a scanner with no input yet. Feed input with ``feed(_:)``
|
|
/// and mark the end of input with ``finish()``.
|
|
public init() {
|
|
scanner = PureYAML.Parsing.LibYAML.Scanner("", isFinal: false)
|
|
}
|
|
|
|
/// Appends a chunk of YAML source. Chunks are concatenated in the
|
|
/// order fed; a chunk need not align with any YAML grammar boundary.
|
|
public mutating func feed(_ chunk: String) {
|
|
scanner.reader.append(chunk)
|
|
}
|
|
|
|
/// Marks that no further input will be fed. After this, ``next()``
|
|
/// never returns ``Outcome/needMoreInput``: a fetch that would have
|
|
/// needed more input instead surfaces as a real scanning error, since
|
|
/// there is no more input coming that could resolve it.
|
|
public mutating func finish() {
|
|
scanner.reader.markFinal()
|
|
}
|
|
|
|
/// Advances the scan by one token. Call in a loop, feeding more input
|
|
/// on ``Outcome/needMoreInput`` and stopping on ``Outcome/done``.
|
|
public mutating func next() throws -> Outcome {
|
|
switch try scanner.scanStep() {
|
|
case let .token(token):
|
|
.token(Token(token))
|
|
case .needMoreInput:
|
|
.needMoreInput
|
|
case .done:
|
|
.done
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PureYAML.Streaming.Token {
|
|
init(_ token: PureYAML.Parsing.LibYAML.Token) {
|
|
self.init(
|
|
kind: Kind(token.type),
|
|
range: PureYAML.Model.SourceRange(
|
|
start: PureYAML.Model.SourcePosition(line: token.startMark.line, column: token.startMark.column),
|
|
end: PureYAML.Model.SourcePosition(line: token.endMark.line, column: token.endMark.column),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
extension PureYAML.Streaming.Token.Kind {
|
|
/// A mechanical 1:1 mapping over all 22 `TokenType` cases, split across
|
|
/// three initializers purely to keep each switch's cyclomatic complexity
|
|
/// under the project's lint limit; the split carries no meaning beyond
|
|
/// that; each `default:` either forwards to the next initializer (the
|
|
/// cases it doesn't itself handle) or, in the last one, is truly
|
|
/// unreachable because the first two together already handle every
|
|
/// non-payload-bearing case.
|
|
init(_ type: PureYAML.Parsing.LibYAML.TokenType) {
|
|
switch type {
|
|
case .streamStart: self = .streamStart
|
|
case .streamEnd: self = .streamEnd
|
|
case .documentStart: self = .documentStart
|
|
case .documentEnd: self = .documentEnd
|
|
case .blockSequenceStart: self = .blockSequenceStart
|
|
case .blockMappingStart: self = .blockMappingStart
|
|
case .blockEnd: self = .blockEnd
|
|
case .blockEntry: self = .blockEntry
|
|
default: self = Self(structural: type)
|
|
}
|
|
}
|
|
|
|
private init(structural type: PureYAML.Parsing.LibYAML.TokenType) {
|
|
switch type {
|
|
case .flowSequenceStart: self = .flowSequenceStart
|
|
case .flowMappingStart: self = .flowMappingStart
|
|
case .flowSequenceEnd: self = .flowSequenceEnd
|
|
case .flowMappingEnd: self = .flowMappingEnd
|
|
case .flowEntry: self = .flowEntry
|
|
case .key: self = .key
|
|
case .value: self = .value
|
|
case .reservedDirective: self = .reservedDirective
|
|
default: self = Self(payload: type)
|
|
}
|
|
}
|
|
|
|
private init(payload type: PureYAML.Parsing.LibYAML.TokenType) {
|
|
switch type {
|
|
case let .versionDirective(major, minor): self = .versionDirective(major: major, minor: minor)
|
|
case let .tagDirective(handle, prefix): self = .tagDirective(handle: handle, prefix: prefix)
|
|
case let .anchor(name): self = .anchor(name: name)
|
|
case let .alias(name): self = .alias(name: name)
|
|
case let .tag(handle, suffix): self = .tag(handle: handle, suffix: suffix)
|
|
case let .scalar(value, style): self = .scalar(value: value, style: PureYAML.Streaming.Token.ScalarStyle(style))
|
|
default: preconditionFailure("unreachable: init(_:) and init(structural:) handle every non-payload TokenType case")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PureYAML.Streaming.Token.ScalarStyle {
|
|
init(_ style: PureYAML.Parsing.ScalarStyle) {
|
|
switch style {
|
|
case .plain: self = .plain
|
|
case .singleQuoted: self = .singleQuoted
|
|
case .doubleQuoted: self = .doubleQuoted
|
|
case .literal: self = .literal
|
|
case .folded: self = .folded
|
|
}
|
|
}
|
|
}
|