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.
123 lines
4.8 KiB
Swift
123 lines
4.8 KiB
Swift
import Foundation
|
|
@testable import PureYAML
|
|
import Testing
|
|
|
|
/// Differential proof for the resumable `Scanner.scanStep()` (issue #4's first
|
|
/// increment: a checkpoint-and-retry wrapper around the existing batch
|
|
/// `fetchNextToken()`, see `LibYAML.Scanner.Streaming.swift`). The scanner is a
|
|
/// pure value type, so `scanStep()` reuses every fetch function unmodified; the
|
|
/// only new behavior is (1) `Reader` flags `ranDry` instead of asserting end of
|
|
/// input when `!isFinal`, and (2) tokens are released one at a time only when no
|
|
/// possible simple key still targets the queue front. Neither is exercised by
|
|
/// `scanAll()`, so this is genuinely new code path, not a refactor, and needs its
|
|
/// own proof: driving the same input through `scanStep()` at several chunk sizes
|
|
/// must reproduce `scanAll()`'s token array (or thrown error) exactly.
|
|
@Suite("Streaming scanner differential")
|
|
struct StreamingScannerDifferentialTests {
|
|
/// A handful of representative always-on cases so `swift test` gets real
|
|
/// coverage without the opt-in whole-suite flag: a plain scalar split
|
|
/// mid-token, a block mapping, a flow collection, an anchor/alias pair, and
|
|
/// a deliberately malformed input (an unterminated quoted scalar).
|
|
@Test("Representative cases match scanAll() at several chunk sizes", arguments: [
|
|
"title: Example API",
|
|
"""
|
|
title: Example
|
|
servers:
|
|
- url: /
|
|
""",
|
|
"flow: {a: 1, b: [2, 3]}",
|
|
"""
|
|
base: &anchor
|
|
value: 1
|
|
derived: *anchor
|
|
""",
|
|
"block: |\n line one\n line two\n",
|
|
"title: \"unterminated",
|
|
"&a a: b\nc: &d d\n",
|
|
])
|
|
func test_representativeCasesMatchScanAllAtSeveralChunkSizes(yaml: String) throws {
|
|
try assertStreamingMatchesBatch(yaml)
|
|
}
|
|
|
|
@Test("Every yaml-test-suite case matches scanAll() at several chunk sizes")
|
|
func test_wholeSuiteMatchesScanAllAtSeveralChunkSizes() throws {
|
|
guard ProcessInfo.processInfo.environment["PUREYAML_RUN_YAML_TEST_SUITE"] == "1" else {
|
|
return
|
|
}
|
|
let manifest = try YAMLTestSuiteManifest.load()
|
|
var mismatches: [String] = []
|
|
for testCase in manifest.cases {
|
|
let yaml = try testCase.loadInput()
|
|
do {
|
|
try assertStreamingMatchesBatch(yaml)
|
|
} catch {
|
|
mismatches.append("\(testCase.id): \(error)")
|
|
}
|
|
}
|
|
#expect(mismatches.isEmpty, "streaming/batch mismatches (\(mismatches.count)): \(mismatches.prefix(10).joined(separator: "; "))")
|
|
}
|
|
}
|
|
|
|
/// Runs the batch scanner once as the reference, then the streaming scanner at
|
|
/// several chunk sizes (including feeding the whole input as a single "chunk",
|
|
/// which should degenerate to one dry run followed by success), and asserts each
|
|
/// streaming run reproduces the reference token array or thrown error exactly.
|
|
private func assertStreamingMatchesBatch(_ yaml: String) throws {
|
|
let reference = batchScan(yaml)
|
|
for chunkSize in [1, 2, 3, 7, 64, yaml.unicodeScalars.count + 1] {
|
|
let observed = try streamingScan(yaml, chunkSize: chunkSize)
|
|
#expect(observed == reference, "chunk size \(chunkSize) diverged from scanAll() for: \(yaml.debugDescription)")
|
|
}
|
|
}
|
|
|
|
private enum ScanResult: Equatable {
|
|
case tokens([PureYAML.Parsing.LibYAML.Token])
|
|
case threw(String)
|
|
}
|
|
|
|
private func batchScan(_ yaml: String) -> ScanResult {
|
|
var scanner = PureYAML.Parsing.LibYAML.Scanner(yaml)
|
|
do {
|
|
return try .tokens(scanner.scanAll())
|
|
} catch {
|
|
return .threw("\(error)")
|
|
}
|
|
}
|
|
|
|
/// Drives `scanStep()` over `yaml` split into `chunkSize`-scalar pieces, feeding
|
|
/// one chunk whenever the scanner reports `.needMoreInput`, and marking the
|
|
/// reader final once every chunk has been appended.
|
|
private func streamingScan(_ yaml: String, chunkSize: Int) throws -> ScanResult {
|
|
let scalars = Array(yaml.unicodeScalars)
|
|
var chunks: [String] = []
|
|
var index = 0
|
|
while index < scalars.count {
|
|
let end = min(index + chunkSize, scalars.count)
|
|
chunks.append(String(String.UnicodeScalarView(scalars[index ..< end])))
|
|
index = end
|
|
}
|
|
|
|
var scanner = PureYAML.Parsing.LibYAML.Scanner("", isFinal: false)
|
|
var nextChunk = 0
|
|
var tokens: [PureYAML.Parsing.LibYAML.Token] = []
|
|
do {
|
|
while true {
|
|
switch try scanner.scanStep() {
|
|
case let .token(token):
|
|
tokens.append(token)
|
|
case .done:
|
|
return .tokens(tokens)
|
|
case .needMoreInput:
|
|
if nextChunk < chunks.count {
|
|
scanner.reader.append(chunks[nextChunk])
|
|
nextChunk += 1
|
|
} else {
|
|
scanner.reader.markFinal()
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
return .threw("\(error)")
|
|
}
|
|
}
|