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

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
}
}