PureYAML/Tests/StreamingScannerDifferentialTests.swift
Mihaela Mihaljevic 18fdf9b69a feat(parsing): add a resumable scanner increment and broaden the mutation suite
Add Scanner.scanStep(), the first increment of true chunked input streaming
(#4): Reader gains isFinal/append(_:)/markFinal(), and peek(_:) flags ranDry
instead of asserting end of input when the buffer runs out mid-fetch and more
chunks may still arrive. scanStep() snapshots the scanner before each
fetchNextToken() and restores it whenever a fetch runs dry, so a construct
spanning a chunk boundary is retried after more input lands rather than
misread as an early end of input. Every existing call site keeps the default
isFinal: true, so scanAll() and batch parsing are unchanged; ranDry can never
be set when isFinal is true, by construction. A token already queued is not
always safe to release, since libyaml's simple-key mechanism can still splice
a KEY token in front of it, so scanStep() ports libyaml's
yaml_parser_fetch_more_tokens release condition.

Proof: StreamingScannerDifferentialTests.swift drives scanStep() at several
chunk sizes over representative cases always, and over the entire
yaml-test-suite manifest (402 cases) behind PUREYAML_RUN_YAML_TEST_SUITE=1,
asserting the token sequence matches scanAll() exactly. That proof caught a
real bug: lastTokenForbidsInlineSequence() read indentEngine.tokens.last,
which goes empty once tokens are released incrementally, silently breaking
the key: - a rejection. Fixed by tracking IndentEngine.lastToken explicitly,
kept in sync with tokens.last so batch behavior (which never dequeues) is
unchanged.

Not yet wired into PureYAMLStreaming; scanStep() and its supporting types stay
internal until that integration is designed.

Also broaden the real-seed mutation property suite (#6) with the three
mutation classes it named but didn't yet cover: invalidControlCharacter,
mixedTabSpaceIndentation, and truncatedFlowCollection. The test now asserts
diagnostic quality, not just pass/fail: line must fall within the mutated
document, and column, when present, must be 1-based (some ParseError cases,
like tabIndentation, are line-only by design).
2026-07-09 23:38:39 +02:00

122 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",
])
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)")
}
}