diff --git a/Sources/Parsing/LibYAML.Scanner.Fetch.swift b/Sources/Parsing/LibYAML.Scanner.Fetch.swift index 4ef95f7..3edc334 100644 --- a/Sources/Parsing/LibYAML.Scanner.Fetch.swift +++ b/Sources/Parsing/LibYAML.Scanner.Fetch.swift @@ -102,6 +102,16 @@ extension PureYAML.Parsing.LibYAML.Scanner { case ":" where flowLevel > 0 || Classify.isBlankZ(reader.peek(1)): try fetchValue() default: + // A plain scalar may begin with any non-blank character; a blank (a + // tab here, since spaces and breaks were already consumed) cannot + // start a token, matching libyaml's final guard in fetch_next_token. + guard !Classify.isBlankZ(character) else { + throw ScannerError( + context: "while scanning for the next token", + problem: "found character that cannot start any token", + mark: reader.mark, + ) + } try fetchPlainScalar() } } diff --git a/Tests/LibYAMLPortConformanceTests.swift b/Tests/LibYAMLPortConformanceTests.swift new file mode 100644 index 0000000..685b7c8 --- /dev/null +++ b/Tests/LibYAMLPortConformanceTests.swift @@ -0,0 +1,66 @@ +import Foundation +@testable import PureYAML +import Testing + +/// Differential measurement of the ported libyaml scanner+parser against the full +/// vendored YAML test suite, using the same yeast serializer as the canonical +/// gate. Gated by an environment flag so it only runs when measuring; it prints a +/// score and the failing ids split into throws vs wrong-stream. +@Suite("LibYAML Port Conformance") +struct LibYAMLPortConformanceTests { + @Test("measure the ported pipeline against the suite") + func test_measure() throws { + guard ProcessInfo.processInfo.environment["PUREYAML_PORT_MEASURE"] == "1" else { return } + var pass = 0 + var throwing: [String] = [] + var wrong: [String] = [] + let cases = CanonicalEventCorpus.validCases() + for testCase in cases { + guard let yaml = testCase.input, let expected = testCase.expectedEvents else { continue } + do { + var parser = try PureYAML.Parsing.LibYAML.Parser(yaml) + let actual = try parser.parse().map(CanonicalEventCorpus.serialize).joined(separator: "\n") + if actual == expected.trimmedTrailing { + pass += 1 + } else { + wrong.append(testCase.id) + } + } catch { + throwing.append(testCase.id) + } + } + print("PORT-SCORE pass=\(pass) wrong=\(wrong.count) throws=\(throwing.count) total=\(cases.count)") + print("PORT-WRONG \(wrong.sorted().joined(separator: " "))") + print("PORT-THROWS \(throwing.sorted().joined(separator: " "))") + } + + @Test("dump port yeast for ids in PUREYAML_PORT_DUMP_IDS") + func test_dump() throws { + guard let ids = ProcessInfo.processInfo.environment["PUREYAML_PORT_DUMP_IDS"] else { return } + let cases = ids == "ALL" + ? CanonicalEventCorpus.validCases().map(\.id) + : ids.split(separator: ",").map(String.init) + for id in cases { + let testCase = CanonicalEventCorpus.Case(id: id) + guard let yaml = testCase.input else { continue } + print("@@@ID@@@ \(id)") + do { + var parser = try PureYAML.Parsing.LibYAML.Parser(yaml) + try print(parser.parse().map(CanonicalEventCorpus.serialize).joined(separator: "\n")) + } catch { + print("@@@ERROR@@@ \(error)") + } + print("@@@END@@@") + } + } +} + +private extension String { + var trimmedTrailing: String { + var copy = self + while copy.hasSuffix("\n") { + copy.removeLast() + } + return copy + } +}