PureYAML/Tests/PublicStreamingScannerTests.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

114 lines
4.2 KiB
Swift

import Foundation
@testable import PureYAML
import Testing
/// Differential proof for the public `PureYAML.Streaming.Scanner`, the thin
/// translation wrapper around the internal `LibYAML.Scanner.scanStep()`
/// already proven by `StreamingScannerDifferentialTests`. That existing proof
/// covers `scanStep()` itself; this one covers the translation layer on top
/// of it (`Streaming.Token`/`Streaming.Token.Kind`/`Streaming.Token.ScalarStyle`
/// mapped from the internal `LibYAML.Token`/`TokenType`/`ScalarStyle`), since a
/// wrong case mapping or a dropped field would not be caught by a proof that
/// never looks at the public types at all.
@Suite("Public streaming scanner")
struct PublicStreamingScannerTests {
@Test("Public Scanner matches the internal scanAll() reference, translated, 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",
"&a a: b\nc: &d d\n",
])
func test_publicScannerMatchesTranslatedReference(yaml: String) throws {
try assertPublicScannerMatchesReference(yaml)
}
@Test("Public Scanner matches the internal scanAll() reference over the whole yaml-test-suite corpus")
func test_publicScannerMatchesReferenceOverWholeSuite() 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 assertPublicScannerMatchesReference(yaml)
} catch {
mismatches.append("\(testCase.id): \(error)")
}
}
#expect(mismatches.isEmpty, "public/internal mismatches (\(mismatches.count)): \(mismatches.prefix(10).joined(separator: "; "))")
}
}
private func assertPublicScannerMatchesReference(_ yaml: String) throws {
let reference = translatedBatchReference(yaml)
for chunkSize in [1, 2, 3, 7, 64, yaml.unicodeScalars.count + 1] {
let observed = try publicScan(yaml, chunkSize: chunkSize)
#expect(observed == reference, "chunk size \(chunkSize) diverged from the translated reference for: \(yaml.debugDescription)")
}
}
private enum PublicScanResult: Equatable {
case tokens([PureYAML.Streaming.Token])
case threw(String)
}
/// The reference: the batch scanner's own tokens, run through the exact same
/// `Streaming.Token.init(_:)` translator the public `Scanner` uses, so this
/// proof isolates "does the public wrapper preserve what the internal scanner
/// already produces" from "is the internal scanner itself correct" (the
/// latter is `StreamingScannerDifferentialTests`'s job, not this one's).
private func translatedBatchReference(_ yaml: String) -> PublicScanResult {
var scanner = PureYAML.Parsing.LibYAML.Scanner(yaml)
do {
return try .tokens(scanner.scanAll().map(PureYAML.Streaming.Token.init))
} catch {
return .threw("\(error)")
}
}
private func publicScan(_ yaml: String, chunkSize: Int) throws -> PublicScanResult {
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.Streaming.Scanner()
var nextChunk = 0
var tokens: [PureYAML.Streaming.Token] = []
do {
while true {
switch try scanner.next() {
case let .token(token):
tokens.append(token)
case .done:
return .tokens(tokens)
case .needMoreInput:
if nextChunk < chunks.count {
scanner.feed(chunks[nextChunk])
nextChunk += 1
} else {
scanner.finish()
}
}
}
} catch {
return .threw("\(error)")
}
}