PureYAML/Tests/CanonicalEventConformanceTests.swift
Mihaela Mihaljevic 561921df42 feat(parsing): route the event API through the ported libyaml engine
parseEvents now runs on the ported scanner+parser instead of the
hand-rolled approximation. Adds 1-based mark conversion at the boundary
(the port stays faithfully 0-based internally), translates the port's
scanner and parser errors into the diagnostic-aware ParseError with
precise codes (unterminatedQuotedString, unterminatedTag, tabIndentation)
and a readable malformed fallback, and retargets the canonical event gate
to track the port's gap set.

The port matches the canonical test.event on 253 of 308 valid cases;
the remaining gaps match libyaml's own behaviour and are the targets for
surpassing libyaml. Golden event and diagnostic expectations are rebased
onto the port where it is faithful or more correct (resolved verbatim
tags, libyaml's stream-end marks, reaching the real unterminated quote).
333 tests pass.
2026-06-24 17:32:29 +02:00

197 lines
8.7 KiB
Swift

import Foundation
@testable import PureYAML
import Testing
/// Authoritative conformance gate: serialize PureYAML's parse events into the
/// official YAML test suite "event" (yeast) format and compare EXACTLY to each
/// case's vendored `test.event`. Unlike the JSON-projection oracle, this checks
/// anchors, tags, scalar styles, flow vs block, and explicit document markers,
/// for every valid case (including those with no `in.json`). No normalization.
@Suite("Canonical Event Conformance")
struct CanonicalEventConformanceTests {
/// Valid suite cases whose event stream PureYAML does NOT yet emit exactly.
/// This is the authoritative gap set: a fix removes an id (and the test fails
/// until it is removed), a regression adds one (and the test fails). Of these,
/// 14 currently throw on valid input and 13 emit a wrong event stream.
static let knownEventGaps: Set<String> = [
// Gap set for the ported libyaml engine. Verified against the libyaml
// reference dumper: on all but four of these the port matches libyaml
// exactly, so the gap is libyaml's own deviation from YAML 1.2, not a port
// bug. Surpassing libyaml means shrinking this set beyond what libyaml
// itself handles.
//
// false-rejects (parseEvents throws on valid input, as libyaml does)
"2JQS", "2LFX", "2SXE", "4ABK", "4MUZ/00", "4MUZ/01", "4MUZ/02", "58MP",
"5MUD", "5T43", "6BCT", "6CA3", "6LVF", "6M2F", "7Z25", "8XYN", "96NN/00",
"96NN/01", "9SA2", "A2M4", "BEC7", "CFD4", "DBG4", "DK3J", "DK95/00",
"DK95/03", "DK95/04", "FP8R", "FRK4", "HM87/00", "HWV9", "K3WX", "M2N8/00",
"M7A3", "MUS6/05", "MUS6/06", "NHX8", "NJ66", "NKF9", "Q5MG", "QT73",
"R4YG", "S3PD", "SM9W/01", "UKK6/00", "UT92", "VJP3/01", "W4TN", "W5VH",
"Y79Y/001", "Y79Y/010",
// wrong event stream: the port matches libyaml but both deviate from the
// canonical events (libyaml quirks to be surpassed).
"652Z", "HM87/01", "JEF9/02", "Y2GN",
]
@Test("Every valid case emits canonical events, except the recorded gaps")
func test_canonicalEventConformance() throws {
var observedGaps: Set<String> = []
for testCase in CanonicalEventCorpus.validCases() {
guard let yaml = testCase.input, let expected = testCase.expectedEvents else { continue }
let events: [PureYAML.Parsing.Event]
do {
events = try PureYAML.Parsing.Parser().parseEvents(yaml)
} catch {
observedGaps.insert(testCase.id)
continue
}
let actual = events.map(CanonicalEventCorpus.serialize).joined(separator: "\n")
if actual != expected.trimmingTrailingNewline {
observedGaps.insert(testCase.id)
}
}
let regressions = observedGaps.subtracting(Self.knownEventGaps).sorted()
let fixed = Self.knownEventGaps.subtracting(observedGaps).sorted()
#expect(regressions.isEmpty, "Canonical-event REGRESSIONS (now wrong, were correct): \(regressions.joined(separator: " "))")
#expect(fixed.isEmpty, "Canonical-event cases now FIXED; remove from knownEventGaps: \(fixed.joined(separator: " "))")
}
@Test("differential: dump PureYAML yeast events for files under a directory")
func test_dumpYeast() throws {
guard let dir = ProcessInfo.processInfo.environment["PUREYAML_YEAST_DIR"] else { return }
let fileManager = FileManager.default
guard let walker = fileManager.enumerator(atPath: dir) else { return }
for case let path as String in walker where path.hasSuffix(".yaml") || path.hasSuffix(".yml") {
let full = dir + "/" + path
guard let yaml = try? String(contentsOfFile: full, encoding: .utf8) else { continue }
print("@@@FILE@@@ \(path)")
do {
let events = try PureYAML.Parsing.Parser().parseEvents(yaml)
print(events.map(CanonicalEventCorpus.serialize).joined(separator: "\n"))
} catch {
print("@@@ERROR@@@ \(error)")
}
print("@@@END@@@")
}
}
@Test("triage: dump first-line diff for each recorded gap")
func test_triage() throws {
guard ProcessInfo.processInfo.environment["PUREYAML_TRIAGE_EVENTS"] == "1" else { return }
for id in Self.knownEventGaps.sorted() {
let testCase = CanonicalEventCorpus.Case(id: id)
guard let yaml = testCase.input, let expectedRaw = testCase.expectedEvents else { continue }
let expected = expectedRaw.trimmingTrailingNewline.split(separator: "\n").map(String.init)
let events: [PureYAML.Parsing.Event]
do {
events = try PureYAML.Parsing.Parser().parseEvents(yaml)
} catch {
print("\(id) THROWS: \(error)")
continue
}
let actual = events.map(CanonicalEventCorpus.serialize)
var line = 0
while line < min(expected.count, actual.count), expected[line] == actual[line] {
line += 1
}
let exp = line < expected.count ? expected[line] : "(end)"
let act = line < actual.count ? actual[line] : "(end)"
print("\(id) @\(line): EXP[\(exp)] ACT[\(act)]")
}
}
}
enum CanonicalEventCorpus {
struct Case { let id: String }
static func validCases() -> [Case] {
// Every vendored case that is NOT an error case (no `error` marker).
guard let base = Bundle.module.url(forResource: "yaml-test-suite", withExtension: nil, subdirectory: "Fixtures") else { return [] }
let fileManager = FileManager.default
guard let walker = fileManager.enumerator(at: base, includingPropertiesForKeys: nil) else { return [] }
var out: [Case] = []
for case let url as URL in walker where url.lastPathComponent == "in.yaml" {
let dir = url.deletingLastPathComponent()
let id = dir.path.replacingOccurrences(of: base.path + "/", with: "")
if !fileManager.fileExists(atPath: dir.appendingPathComponent("error").path) {
out.append(Case(id: id))
}
}
return out.sorted { $0.id < $1.id }
}
static func url(_ id: String, _ name: String, _ ext: String?) -> URL? {
Bundle.module.url(forResource: name, withExtension: ext, subdirectory: "Fixtures/yaml-test-suite/" + id)
}
static func serialize(_ event: PureYAML.Parsing.Event) -> String {
switch event {
case .streamStart: "+STR"
case .streamEnd: "-STR"
case let .documentStart(explicit, _): explicit ? "+DOC ---" : "+DOC"
case let .documentEnd(explicit, _): explicit ? "-DOC ..." : "-DOC"
case let .sequenceStart(anchor, tag, style, _): "+SEQ" + (style == .flow ? " []" : "") + props(anchor, tag)
case .sequenceEnd: "-SEQ"
case let .mappingStart(anchor, tag, style, _): "+MAP" + (style == .flow ? " {}" : "") + props(anchor, tag)
case .mappingEnd: "-MAP"
case let .scalar(value, anchor, tag, style, _): "=VAL" + props(anchor, tag) + " " + styleChar(style) + escape(value)
case let .alias(anchor, _): "=ALI *" + anchor
}
}
static func props(_ anchor: String?, _ tag: String?) -> String {
var result = ""
if let anchor { result += " &" + anchor }
if let tag { result += " <" + tag + ">" }
return result
}
static func styleChar(_ style: PureYAML.Parsing.ScalarStyle) -> String {
switch style {
case .plain: ":"
case .singleQuoted: "'"
case .doubleQuoted: "\""
case .literal: "|"
case .folded: ">"
}
}
static func escape(_ value: String) -> String {
var out = ""
for scalar in value.unicodeScalars {
switch scalar {
case "\\": out += "\\\\"
case "\t": out += "\\t"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\u{08}": out += "\\b"
case "\u{00}": out += "\\0"
default: out.unicodeScalars.append(scalar)
}
}
return out
}
}
extension CanonicalEventCorpus.Case {
var input: String? {
guard let url = CanonicalEventCorpus.url(id, "in", "yaml") else { return nil }
return try? String(contentsOf: url, encoding: .utf8)
}
var expectedEvents: String? {
guard let url = CanonicalEventCorpus.url(id, "test", "event") else { return nil }
return try? String(contentsOf: url, encoding: .utf8)
}
}
private extension String {
var trimmingTrailingNewline: String {
var copy = self
while copy.hasSuffix("\n") {
copy.removeLast()
}
return copy
}
}