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).
267 lines
9.6 KiB
Swift
267 lines
9.6 KiB
Swift
import Foundation
|
|
@testable import PureYAML
|
|
import Testing
|
|
|
|
@Suite("Real YAML Corpus Properties")
|
|
struct RealYAMLCorpusPropertyTests {
|
|
@Test("Generated valid YAML documents parse dump and parse stably")
|
|
func test_generatedValidYAMLDocumentsParseDumpAndParseStably() throws {
|
|
var generator = DeterministicGenerator(seed: 0x5055_5941_4D4C)
|
|
|
|
for index in 0 ..< 100 {
|
|
let yaml = generator.validYAMLDocument(index: index)
|
|
let documents = try PureYAML.parseStream(yaml)
|
|
try PureYAML.validate(documents)
|
|
|
|
let dumped = PureYAML.dump(documents)
|
|
let reparsed = try PureYAML.parseStream(dumped)
|
|
|
|
#expect(reparsed == documents, "generated case \(index) changed after parse/dump/parse")
|
|
}
|
|
}
|
|
|
|
@Test("Real seed mutations return structured diagnostics without crashing")
|
|
func test_realSeedMutationsReturnStructuredDiagnosticsWithoutCrashing() throws {
|
|
let seeds = try PropertyCorpusSeed.loadAll()
|
|
.filter { $0.tier == "default" }
|
|
.prefix(10)
|
|
|
|
for seed in seeds {
|
|
let source = try seed.load()
|
|
for mutation in PropertyMutation.allCases {
|
|
let mutated = mutation.apply(to: source, seedID: seed.id)
|
|
let mutatedLineCount = mutated.split(separator: "\n", omittingEmptySubsequences: false).count
|
|
let report = PureYAML.diagnosticValidationReport(mutated, file: "\(seed.id)-\(mutation.rawValue).yaml")
|
|
|
|
#expect(!report.diagnostics.isEmpty, "\(seed.id) \(mutation.rawValue) produced no diagnostics")
|
|
#expect(report.diagnostics.allSatisfy { $0.kind == .parse })
|
|
let codes = report.diagnostics.map { $0.code ?? "nil" }.joined(separator: ",")
|
|
let matching = report.diagnostics.filter { $0.code == mutation.expectedCode }
|
|
#expect(
|
|
!matching.isEmpty,
|
|
"\(seed.id) \(mutation.rawValue) expected \(mutation.expectedCode), got \(codes)",
|
|
)
|
|
for diagnostic in matching {
|
|
#expect(!(diagnostic.reason.isEmpty), "\(seed.id) \(mutation.rawValue) has an empty reason")
|
|
let line = try #require(diagnostic.line, "\(seed.id) \(mutation.rawValue) is missing a line")
|
|
#expect(
|
|
(1 ... mutatedLineCount).contains(line),
|
|
"\(seed.id) \(mutation.rawValue) line \(line) is out of range 1...\(mutatedLineCount)",
|
|
)
|
|
// Some `ParseError` cases (e.g. `.tabIndentation`) are line-only by
|
|
// design; a preflight-scanner diagnostic for the same code carries a
|
|
// column too. Check it when present rather than requiring it always.
|
|
if let column = diagnostic.column {
|
|
#expect(column >= 1, "\(seed.id) \(mutation.rawValue) column \(column) is not 1-based")
|
|
}
|
|
}
|
|
#expect(!report.description.isEmpty)
|
|
#expect(!report.jsonDescription(title: "Generated Corpus Mutation").isEmpty)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct DeterministicGenerator {
|
|
private var state: UInt64
|
|
|
|
init(seed: UInt64) {
|
|
state = seed
|
|
}
|
|
|
|
mutating func validYAMLDocument(index: Int) -> String {
|
|
let service = "service-\(index)-\(nextInt(10000))"
|
|
let replicas = nextInt(5) + 1
|
|
let enabled = nextInt(2) == 0 ? "true" : "false"
|
|
let threshold = "\(nextInt(100)).\(nextInt(10))"
|
|
let firstPort = 8000 + nextInt(100)
|
|
let secondPort = 9000 + nextInt(100)
|
|
return """
|
|
---
|
|
metadata:
|
|
name: \(service)
|
|
labels: {app: pureyaml, generated: "true"}
|
|
spec:
|
|
replicas: \(replicas)
|
|
enabled: \(enabled)
|
|
threshold: \(threshold)
|
|
ports:
|
|
- name: http
|
|
port: \(firstPort)
|
|
- name: metrics
|
|
port: \(secondPort)
|
|
env:
|
|
LOG_LEVEL: "info"
|
|
FEATURE_FLAG: "\(enabled)"
|
|
"""
|
|
}
|
|
|
|
private mutating func nextInt(_ upperBound: Int) -> Int {
|
|
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
|
|
return Int((state >> 32) % UInt64(upperBound))
|
|
}
|
|
}
|
|
|
|
private enum PropertyMutation: String, CaseIterable {
|
|
case missingMappingSpace
|
|
case tabIndentation
|
|
case missingSequenceSpace
|
|
case unterminatedQuotedString
|
|
case undefinedAlias
|
|
case invalidControlCharacter
|
|
case mixedTabSpaceIndentation
|
|
case truncatedFlowCollection
|
|
|
|
var expectedCode: String {
|
|
switch self {
|
|
case .missingMappingSpace:
|
|
"missingMappingSpace"
|
|
case .tabIndentation, .mixedTabSpaceIndentation:
|
|
"tabIndentation"
|
|
case .missingSequenceSpace:
|
|
"missingSequenceSpace"
|
|
case .unterminatedQuotedString:
|
|
"unterminatedQuotedString"
|
|
case .undefinedAlias:
|
|
"undefinedAlias"
|
|
case .invalidControlCharacter:
|
|
"invalidControlCharacter"
|
|
case .truncatedFlowCollection:
|
|
"malformed"
|
|
}
|
|
}
|
|
|
|
/// Mutations whose broken content is appended after the embedded, untouched
|
|
/// real-seed sample rather than before it: an unterminated construct must be
|
|
/// the last thing in the document, since anything appended after it would
|
|
/// either close it (defeating the mutation) or itself become unparseable
|
|
/// content inside it (making the resulting diagnostic unpredictable).
|
|
private var isAppendedAfterSample: Bool {
|
|
self == .unterminatedQuotedString || self == .truncatedFlowCollection
|
|
}
|
|
|
|
/// The broken line for a mutation whose content is appended after the
|
|
/// sample. Only meaningful when `isAppendedAfterSample` is true.
|
|
private var appendedSuffix: String {
|
|
switch self {
|
|
case .unterminatedQuotedString:
|
|
"mutation: \"unterminated scalar"
|
|
case .truncatedFlowCollection:
|
|
"mutation: [a, b, c"
|
|
default:
|
|
""
|
|
}
|
|
}
|
|
|
|
func apply(to source: String, seedID: String) -> String {
|
|
let sample = source.split(separator: "\n", omittingEmptySubsequences: false)
|
|
.prefix(3)
|
|
.joined(separator: "\n")
|
|
if isAppendedAfterSample {
|
|
return """
|
|
# mutation-source: \(seedID)
|
|
sample: |
|
|
\(sample.replacingOccurrences(of: "\n", with: "\n "))
|
|
\(appendedSuffix)
|
|
"""
|
|
}
|
|
|
|
let prefix = switch self {
|
|
case .missingMappingSpace:
|
|
"mutation:Missing mapping space"
|
|
case .tabIndentation:
|
|
"\tmutation: tab indentation"
|
|
case .missingSequenceSpace:
|
|
"-mutation sequence marker"
|
|
case .undefinedAlias:
|
|
"mutation: *undefinedMutationAlias"
|
|
case .invalidControlCharacter:
|
|
"mutation: bad\u{0}value"
|
|
case .mixedTabSpaceIndentation:
|
|
" \tmutation: mixed tab and space indentation"
|
|
case .unterminatedQuotedString, .truncatedFlowCollection:
|
|
""
|
|
}
|
|
return """
|
|
# mutation-source: \(seedID)
|
|
\(prefix)
|
|
sample: |
|
|
\(sample.replacingOccurrences(of: "\n", with: "\n "))
|
|
"""
|
|
}
|
|
}
|
|
|
|
private struct PropertyCorpusSeed {
|
|
var id: String
|
|
var localPath: String
|
|
var tier: String
|
|
|
|
static func loadAll() throws -> [Self] {
|
|
let url = try #require(Bundle.module.url(
|
|
forResource: "real-yaml-corpus",
|
|
withExtension: "yaml",
|
|
subdirectory: "Fixtures",
|
|
))
|
|
let manifest = try PureYAML.parse(String(contentsOf: url, encoding: .utf8))
|
|
let root = try #require(manifest.mapping)
|
|
let seedValues = try #require(root.sequence("seeds"))
|
|
return try seedValues.map { value in
|
|
let mapping = try #require(value.mapping)
|
|
return try PropertyCorpusSeed(
|
|
id: mapping.requiredString("id"),
|
|
localPath: mapping.requiredString("localPath"),
|
|
tier: mapping.requiredString("tier"),
|
|
)
|
|
}
|
|
}
|
|
|
|
func load() throws -> String {
|
|
let url = try #require(Bundle.module.url(
|
|
forResource: resourceName,
|
|
withExtension: resourceExtension,
|
|
subdirectory: resourceSubdirectory,
|
|
))
|
|
return try String(contentsOf: url, encoding: .utf8)
|
|
}
|
|
|
|
private var resourceSubdirectory: String {
|
|
let directory = localPath.split(separator: "/").dropLast().joined(separator: "/")
|
|
return directory.isEmpty ? "Fixtures" : "Fixtures/\(directory)"
|
|
}
|
|
|
|
private var resourceFileName: String {
|
|
String(localPath.split(separator: "/").last ?? "")
|
|
}
|
|
|
|
private var resourceName: String {
|
|
guard let dotIndex = resourceFileName.lastIndex(of: ".") else {
|
|
return resourceFileName
|
|
}
|
|
return String(resourceFileName[..<dotIndex])
|
|
}
|
|
|
|
private var resourceExtension: String {
|
|
guard let dotIndex = resourceFileName.lastIndex(of: ".") else {
|
|
return ""
|
|
}
|
|
return String(resourceFileName[resourceFileName.index(after: dotIndex)...])
|
|
}
|
|
}
|
|
|
|
private extension PureYAML.Model.Mapping {
|
|
func requiredString(_ key: String) throws -> String {
|
|
guard case let .string(value)? = self[key] else {
|
|
throw PropertyCorpusError("missing string key \(key)")
|
|
}
|
|
return value
|
|
}
|
|
}
|
|
|
|
private struct PropertyCorpusError: Error, CustomStringConvertible {
|
|
var description: String
|
|
|
|
init(_ description: String) {
|
|
self.description = description
|
|
}
|
|
}
|