From 208b8920191206409341c2abef8e5c82c146130a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 6 Jun 2026 07:57:48 +0200 Subject: [PATCH] feat(parser): harden diagnostics and streaming Add structured validation diagnostics, JSON and Yams comparison gates, and lazy token/event parsing so production behavior is measured without retaining full intermediate parser arrays. --- .github/workflows/ci.yml | 26 ++ CHANGELOG.md | 45 +++ README.md | 45 ++- Sources/Emitting/Dumper.swift | 5 +- Sources/Parsing/EventStreamComposer.swift | 318 ++++++++++++++++++ Sources/Parsing/ParseError.swift | 96 ++++++ Sources/Parsing/Parser.Events.swift | 32 ++ Sources/Parsing/Parser.swift | 8 +- Sources/Parsing/Reader.swift | 56 ++- Sources/Parsing/Scalar.swift | 109 +++++- Sources/Parsing/Scanner.FlowContext.swift | 11 + Sources/Parsing/Scanner.Helpers.swift | 15 +- Sources/Parsing/Scanner.swift | 30 +- Sources/Parsing/TokenCursor.swift | 105 +++++- Sources/Parsing/TokenEventParser.Block.swift | 20 +- Sources/Parsing/TokenEventParser.Flow.swift | 16 +- .../Parsing/TokenEventParser.Helpers.swift | 40 +-- Sources/Parsing/TokenEventParser.swift | 90 +++-- Sources/Validation/DiagnosticValidation.swift | 65 +--- Sources/Validation/PreflightScanner.swift | 7 + .../Validation/Validation.Diagnostic.swift | 3 + Sources/Validation/Validation.Report.swift | 15 + Tests/ACMEZephyrFixtureTests.swift | 145 ++++++++ Tests/DiagnosticValidationReportTests.swift | 61 ++++ Tests/DumperTests.swift | 4 +- Tests/EventStreamingParserTests.swift | 167 +++++++++ Tests/JSONCompatibilityTests.swift | 171 ++++++++++ Tests/ParsingCompatibilityTests.swift | 25 ++ Tests/PerformancePhaseTests.swift | 298 ++++++++++++++++ Tests/PreflightScannerTests.swift | 26 ++ Tests/RealYAMLCorpusPropertyTests.swift | 217 ++++++++++++ Tests/RealYAMLCorpusTests.swift | 176 ++++++++++ Tests/ScannerTests.swift | 20 ++ Tests/ValidationReportTests.swift | 12 +- docs/CORPUS.md | 133 +++++++- docs/RELEASE.md | 20 +- docs/releases/pureyaml-0-1-2.md | 49 +++ scripts/check-corpus.sh | 140 ++++++++ scripts/check-json-compatibility.sh | 224 ++++++++++++ scripts/check-performance-phases.sh | 14 + scripts/check-throughput.sh | 182 ++++++++++ scripts/check-yams-diagnostics.sh | 203 +++++++++++ scripts/check-yams-differential.sh | 221 ++++++++++++ scripts/check-yams-validation.sh | 202 +++++++++++ 44 files changed, 3654 insertions(+), 213 deletions(-) create mode 100644 Sources/Parsing/EventStreamComposer.swift create mode 100644 Sources/Parsing/Scanner.FlowContext.swift create mode 100644 Tests/ACMEZephyrFixtureTests.swift create mode 100644 Tests/EventStreamingParserTests.swift create mode 100644 Tests/JSONCompatibilityTests.swift create mode 100644 Tests/PerformancePhaseTests.swift create mode 100644 Tests/RealYAMLCorpusPropertyTests.swift create mode 100644 Tests/RealYAMLCorpusTests.swift create mode 100644 docs/releases/pureyaml-0-1-2.md create mode 100755 scripts/check-corpus.sh create mode 100755 scripts/check-json-compatibility.sh create mode 100755 scripts/check-performance-phases.sh create mode 100755 scripts/check-throughput.sh create mode 100755 scripts/check-yams-diagnostics.sh create mode 100755 scripts/check-yams-differential.sh create mode 100755 scripts/check-yams-validation.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12d034e..5896c4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" permissions: contents: read @@ -86,3 +89,26 @@ jobs: run: | . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" bash scripts/check-wasm.sh + + corpus: + name: Corpus + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - name: Run corpus gates + run: | + bash scripts/check-json-compatibility.sh + bash scripts/check-corpus.sh + bash scripts/check-yams-differential.sh + bash scripts/check-yams-diagnostics.sh + bash scripts/check-yams-validation.sh + bash scripts/check-throughput.sh + bash scripts/check-performance-phases.sh + - name: Upload corpus artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: pureyaml-corpus-${{ runner.os }}-${{ github.sha }} + path: .build/pureyaml-artifacts + retention-days: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b323f..bcbc094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2] - 2026-06-06 + ### Added - Document the real-world YAML corpus, fuzz/property gate, and CI artifact contract for production hardening. +- Add machine-readable parser diagnostic codes to validation report payloads and + cover malformed production-shaped YAML with 10+ structured diagnostics. +- Vendor a 114-file real-world YAML corpus with a machine-readable manifest, + default metadata checks, and an opt-in full parse/validate/round-trip gate. +- Add large public APIs.guru Stripe and Zoom OpenAPI YAML specs to the opt-in + corpus gate. +- Add a reduced synthetic ACME Zephyr fixture set for Stitcher-style multi-file + OpenAPI inputs and bundled output. +- Add a full renamed Birch-style ACME fixture catalog with 594 YAML files, + preserving module/file structure while replacing private organization names, + domains, and documentation URLs. +- Add YAML 1.2 JSON-subset compatibility for compact flow mappings such as + `{"openapi":"3.0.0"}`, including regression coverage for colons inside flow + mapping values. +- Add JSON compatibility tests and artifacts that compare PureYAML against + Swift's JSON decoder across JSON roots, escapes, Unicode surrogate pairs, + exponent numbers, nested values, and whitespace. +- Preserve round-trip fidelity for mapping keys that require quotes, including + trailing-space keys found in the large Zoom OpenAPI corpus seed. +- Add an opt-in Yams differential script that compares parse behavior for the + checked-in real corpus without adding Yams to the PureYAML package manifest. +- Add an opt-in Yams diagnostic comparison script that records PureYAML + structured malformed-input diagnostics alongside Yams parser errors. +- Add an opt-in Yams validation comparison script proving structured duplicate + key validation on YAML that both parsers can load. +- Add an opt-in Yams throughput script and scheduled/manual CI artifact for + release-mode parser performance comparison on representative corpus seeds. +- Add an opt-in PureYAML phase profiler that times scanner, event parser, + composer, lazy parse, validation, and dumping phases on slow corpus seeds. +- Compose normal parser output from events as they are emitted, avoiding a full + intermediate event array while keeping `parseEvents` available for explicit + event materialization. +- Lazily scan tokens for normal `parse` and `parseStream` calls, avoiding a + retained full token array on the main parser path. +- Track reader positions with a UTF-8 cursor so ASCII-heavy scanner advancement + avoids per-character string allocation while preserving byte-accurate marks. +- Add deterministic generated corpus properties for valid YAML round trips and + malformed real-seed mutations with structured diagnostics. +- Write corpus run artifacts under `.build/pureyaml-artifacts/`, including real + seed summaries, generated mutation summaries, seed metadata, and environment + details. +- Add a scheduled/manual CI corpus job that runs the corpus and Yams + differential gates and uploads the generated artifact bundle. ## [0.1.1] - 2026-06-06 diff --git a/README.md b/README.md index a1b8b97..45a1020 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,19 @@ flowchart TB LReview["PR in review"]:::review LEpic["Epic grouping"]:::epic LTodo["Open issue, no PR"]:::todo - LDone ~~~ LReview - LReview ~~~ LEpic - LEpic ~~~ LTodo + LDone --> LReview + LReview --> LEpic + LEpic --> LTodo ``` Parser Replacement Roadmap #8 is complete in main. -Production Hardening and Validation Readiness #54 is active. It tracks the -remaining production-readiness work after current Yams parse-success parity on -the checked corpus: +Production Hardening and Validation Readiness #54 is active. PureYAML now has +checked-corpus Yams parse-success parity, structured diagnostics and validation, +JSON compatibility proof gates, internal token/event streaming, real-corpus +property tests, and repeatable performance comparison artifacts. Remaining work +is concentrated in downstream integration, true chunked input streaming, broader +malformed fixtures, and continued throughput optimization: ```mermaid flowchart TB @@ -53,25 +56,33 @@ flowchart TB classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827 classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827 E54["#54 Production hardening epic"]:::epic - I49["#49 Structured validation reports"]:::todo - I50["#50 False-positive diagnostic triage"]:::todo + I49["#49 Structured validation reports"]:::done + I50["#50 False-positive diagnostic triage"]:::done I51["#51 Bad-YAML mutation suite"]:::todo - I55["#55 Real corpus and fuzz gates"]:::todo + I55["#55 Corpus fuzz and property gates"]:::done + I44["#44 Humongous YAML corpus gate"]:::done + I45["#45 Yams execution and diagnostics comparison"]:::done + I47["#47 Yams-parseable OpenAPI gaps"]:::done I52["#52 OpenAPIDoctor integration dry run"]:::todo - I53["#53 Yams differential regression gate"]:::todo + I53["#53 Yams differential regression gate"]:::done + I43["#43 True chunked input streaming"]:::todo I48["#48 Parser throughput toward libyaml parity"]:::todo E54 --> I49 - E54 --> I50 - E54 --> I51 - E54 --> I55 - E54 --> I52 - E54 --> I53 - E54 --> I48 + I49 --> I50 + I50 --> I51 + I51 --> I55 + I55 --> I44 + I44 --> I45 + I45 --> I47 + I47 --> I53 + I53 --> I52 + I52 --> I43 + I43 --> I48 ``` ## Status -The current release is 0.1.1. It includes block mappings, block sequences, +The current release is 0.1.2. It includes block mappings, block sequences, ordered mappings, common scalars, quoted strings, comments, flow collections, literal and folded block scalars, anchors, aliases, YAML directives, document markers, explicit built-in scalar tags, merge-key expansion, complex mapping keys, diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index 6f07c78..c2fea45 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -352,10 +352,7 @@ extension PureYAML.Emitting.Dumper { } func escapeKey(_ key: String) -> String { - if key.isEmpty || key.contains(":") || key.contains("#") || key.contains("\n") { - return quote(key) - } - return key + canRenderPlainString(key) ? key : quote(key) } func quote(_ value: String) -> String { diff --git a/Sources/Parsing/EventStreamComposer.swift b/Sources/Parsing/EventStreamComposer.swift new file mode 100644 index 0000000..3945ad3 --- /dev/null +++ b/Sources/Parsing/EventStreamComposer.swift @@ -0,0 +1,318 @@ +extension PureYAML.Parsing { + enum EventStreamComposerMode { + case singleDocument + case stream + } + + struct EventStreamComposedNode { + var value: PureYAML.Model.Value + var mappingKey: PureYAML.Parsing.EventComposer.ComposedMappingKey + var mark: PureYAML.Parsing.Mark + var sequenceItems: [EventStreamComposedNode] = [] + } + + enum EventStreamContainerKind { + case sequence + case mapping + } + + struct EventStreamContainer { + var kind: EventStreamContainerKind + var anchor: String? + var values: [EventStreamComposedNode] = [] + var mergeSources: [[PureYAML.Model.Pair]] = [] + var localPairs: [PureYAML.Model.Pair] = [] + var pendingKey: PureYAML.Parsing.EventComposer.ComposedMappingKey? + var mark: PureYAML.Parsing.Mark + } + + struct EventStreamComposer { + let scalarParser: Parser + let mode: EventStreamComposerMode + var anchors: [String: PureYAML.Model.Value] = [:] + var containers: [EventStreamContainer] = [] + var documentValues: [PureYAML.Model.Value] = [] + var currentDocumentValue: PureYAML.Model.Value? + var didSeeStreamStart = false + var didSeeStreamEnd = false + + init( + scalarParser: Parser, + mode: EventStreamComposerMode, + ) { + self.scalarParser = scalarParser + self.mode = mode + } + + mutating func consume(_ event: PureYAML.Parsing.Event) throws { + switch event { + case .streamStart: + didSeeStreamStart = true + case .streamEnd: + didSeeStreamEnd = true + case let .documentStart(mark): + try startDocument(mark: mark) + case let .documentEnd(mark): + try endDocument(mark: mark) + case let .scalar(value, anchor, tag, style, mark): + try consumeScalar(value, anchor: anchor, tag: tag, style: style, mark: mark) + case let .alias(anchor, mark): + try consumeAlias(anchor, mark: mark) + case let .sequenceStart(anchor, _, _, mark): + containers.append(.init(kind: .sequence, anchor: anchor, mark: mark)) + case let .sequenceEnd(mark): + try completeSequence(mark: mark) + case let .mappingStart(anchor, _, _, mark): + containers.append(.init(kind: .mapping, anchor: anchor, mark: mark)) + case let .mappingEnd(mark): + try completeMapping(mark: mark) + } + } + + mutating func composedValue() throws -> PureYAML.Model.Value { + guard didSeeStreamStart, didSeeStreamEnd else { + throw unexpectedEvent(expected: "complete YAML stream", mark: .start) + } + guard documentValues.count == 1, let value = documentValues.first else { + throw unexpectedEvent(expected: "single YAML document", mark: .start) + } + return value + } + + mutating func composedStream() throws -> [PureYAML.Stream.Document] { + guard didSeeStreamStart, didSeeStreamEnd else { + throw unexpectedEvent(expected: "complete YAML stream", mark: .start) + } + return documentValues.enumerated().map { index, value in + PureYAML.Stream.Document(index: index, value: value) + } + } + } +} + +private extension PureYAML.Parsing.EventStreamComposer { + mutating func startDocument(mark: PureYAML.Parsing.Mark) throws { + guard containers.isEmpty else { + throw unexpectedEvent(expected: "document content end", mark: mark) + } + if mode == .singleDocument, !documentValues.isEmpty { + throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: mark.line) + } + anchors = [:] + currentDocumentValue = nil + } + + mutating func endDocument(mark: PureYAML.Parsing.Mark) throws { + guard containers.isEmpty else { + throw unexpectedEvent(expected: "document content end", mark: mark) + } + documentValues.append(currentDocumentValue ?? .null) + currentDocumentValue = nil + } + + mutating func consumeScalar( + _ value: String, + anchor: String?, + tag: String?, + style: PureYAML.Parsing.ScalarStyle, + mark: PureYAML.Parsing.Mark, + ) throws { + let model = try scalarParser.composeScalarValue(value, tag: tag, style: style, mark: mark) + if let anchor { + anchors[anchor] = .string(value) + } + try complete(.init( + value: model, + mappingKey: .init( + keyNode: .string(value), + scalarValue: value, + tag: scalarParser.normalizedTag(tag), + style: style, + ), + mark: mark, + )) + } + + mutating func consumeAlias( + _ anchor: String, + mark: PureYAML.Parsing.Mark, + ) throws { + guard let value = anchors[anchor] else { + throw PureYAML.Parsing.ParseError.undefinedAlias( + anchor: anchor, + line: mark.line, + column: mark.column, + ) + } + try complete(.init( + value: value, + mappingKey: .init( + keyNode: PureYAML.Model.Key(value: value), + scalarValue: nil, + tag: nil, + style: nil, + ), + mark: mark, + )) + } + + mutating func completeSequence(mark: PureYAML.Parsing.Mark) throws { + guard let container = containers.popLast(), container.kind == .sequence else { + throw unexpectedEvent(expected: "sequence start", mark: mark) + } + let value = PureYAML.Model.Value.sequence(container.values.map(\.value)) + store(value, anchor: container.anchor) + try complete(.init( + value: value, + mappingKey: .init( + keyNode: PureYAML.Model.Key(value: value), + scalarValue: nil, + tag: nil, + style: nil, + ), + mark: container.mark, + sequenceItems: container.values, + )) + } + + mutating func completeMapping(mark: PureYAML.Parsing.Mark) throws { + guard let container = containers.popLast(), container.kind == .mapping else { + throw unexpectedEvent(expected: "mapping start", mark: mark) + } + guard container.pendingKey == nil else { + throw unexpectedEvent(expected: "mapping value", mark: mark) + } + let pairs = mergedPairs( + from: container.mergeSources, + localPairs: container.localPairs, + ) + let value = PureYAML.Model.Value.mapping(.init(pairs)) + store(value, anchor: container.anchor) + try complete(.init( + value: value, + mappingKey: .init( + keyNode: PureYAML.Model.Key(value: value), + scalarValue: nil, + tag: nil, + style: nil, + ), + mark: container.mark, + )) + } + + mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode) throws { + guard !containers.isEmpty else { + currentDocumentValue = node.value + return + } + + switch containers[containers.count - 1].kind { + case .sequence: + containers[containers.count - 1].values.append(node) + case .mapping: + try completeMappingNode(node) + } + } + + mutating func completeMappingNode(_ node: PureYAML.Parsing.EventStreamComposedNode) throws { + let index = containers.count - 1 + guard let key = containers[index].pendingKey else { + containers[index].pendingKey = node.mappingKey + return + } + + if key.isMergeKey { + try containers[index].mergeSources.append(contentsOf: mergeSources( + from: node, + )) + } else { + containers[index].localPairs.append(.init(keyNode: key.keyNode, value: node.value)) + } + containers[index].pendingKey = nil + } + + mutating func store( + _ value: PureYAML.Model.Value, + anchor: String?, + ) { + guard let anchor else { + return + } + anchors[anchor] = value + } + + func mergeSources( + from node: PureYAML.Parsing.EventStreamComposedNode, + ) throws -> [[PureYAML.Model.Pair]] { + switch node.value { + case let .mapping(mapping): + return [mapping.pairs] + case let .sequence(values): + guard !node.sequenceItems.isEmpty else { + return try values.map { value in + guard case let .mapping(mapping) = value else { + throw invalidMergeValue(mark: node.mark) + } + return mapping.pairs + } + } + return try values.enumerated().map { index, value in + let mark = node.sequenceItems[index].mark + guard case let .mapping(mapping) = value else { + throw invalidMergeValue(mark: mark) + } + return mapping.pairs + } + case .bool, .double, .int, .null, .string: + throw invalidMergeValue(mark: node.mark) + } + } + + func mergedPairs( + from mergeSources: [[PureYAML.Model.Pair]], + localPairs: [PureYAML.Model.Pair], + ) -> [PureYAML.Model.Pair] { + guard !mergeSources.isEmpty else { + return localPairs + } + + let localKeys = Set(localPairs.map(\.keyNode)) + var inheritedKeys = Set() + var inheritedPairs: [PureYAML.Model.Pair] = [] + for source in mergeSources { + var sourceKeys = Set() + for pair in source { + guard !localKeys.contains(pair.keyNode), + !inheritedKeys.contains(pair.keyNode) + else { + continue + } + inheritedPairs.append(pair) + sourceKeys.insert(pair.keyNode) + } + inheritedKeys.formUnion(sourceKeys) + } + return inheritedPairs + localPairs + } + + func invalidMergeValue( + mark: PureYAML.Parsing.Mark, + ) -> PureYAML.Parsing.ParseError { + PureYAML.Parsing.ParseError.invalidMergeValue( + line: mark.line, + column: mark.column, + ) + } + + func unexpectedEvent( + expected: String, + mark: PureYAML.Parsing.Mark, + ) -> PureYAML.Parsing.ParseError { + PureYAML.Parsing.ParseError.unexpectedEvent( + expected: expected, + actual: "streaming event", + line: mark.line, + column: mark.column, + ) + } +} diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index 0ac7428..5d89794 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -63,5 +63,101 @@ public extension PureYAML.Parsing { "expected \(expected) at line \(line), column \(column), found \(actual)" } } + + public var diagnosticCode: String { + switch self { + case .emptyDocument: + "emptyDocument" + case .tabIndentation: + "tabIndentation" + case .unexpectedIndentation: + "unexpectedIndentation" + case .mixedCollectionStyles: + "mixedCollectionStyles" + case .expectedMappingKey: + "expectedMappingKey" + case .expectedAnchorName: + "expectedAnchorName" + case .expectedAliasName: + "expectedAliasName" + case .expectedNode: + "expectedNode" + case .expectedScalarKey: + "expectedScalarKey" + case .incompatibleYAMLDirective: + "incompatibleYAMLDirective" + case .invalidTaggedScalar: + "invalidTaggedScalar" + case .invalidMergeValue: + "invalidMergeValue" + case .unterminatedTag: + "unterminatedTag" + case .unterminatedQuotedString: + "unterminatedQuotedString" + case .unsupportedDirective: + "unsupportedDirective" + case .unsupportedMultiDocumentStream: + "unsupportedMultiDocumentStream" + case .undefinedAlias: + "undefinedAlias" + case .unexpectedEvent: + "unexpectedEvent" + case .unexpectedToken: + "unexpectedToken" + } + } + + public var sourceLine: Int? { + switch self { + case .emptyDocument: + nil + case let .tabIndentation(line), + let .unexpectedIndentation(line), + let .mixedCollectionStyles(line), + let .expectedMappingKey(line), + let .expectedAnchorName(line), + let .expectedAliasName(line), + let .incompatibleYAMLDirective(line), + let .unterminatedTag(line), + let .unterminatedQuotedString(line), + let .unsupportedDirective(_, line), + let .unsupportedMultiDocumentStream(line): + line + case let .expectedNode(line, _), + let .expectedScalarKey(line, _), + let .invalidTaggedScalar(_, _, line, _), + let .invalidMergeValue(line, _), + let .undefinedAlias(_, line, _), + let .unexpectedEvent(_, _, line, _), + let .unexpectedToken(_, _, line, _): + line + } + } + + public var sourceColumn: Int? { + switch self { + case .emptyDocument, + .tabIndentation, + .unexpectedIndentation, + .mixedCollectionStyles, + .expectedMappingKey, + .expectedAnchorName, + .expectedAliasName, + .incompatibleYAMLDirective, + .unterminatedTag, + .unterminatedQuotedString, + .unsupportedDirective, + .unsupportedMultiDocumentStream: + nil + case let .expectedNode(_, column), + let .expectedScalarKey(_, column), + let .invalidTaggedScalar(_, _, _, column), + let .invalidMergeValue(_, column), + let .undefinedAlias(_, _, column), + let .unexpectedEvent(_, _, _, column), + let .unexpectedToken(_, _, _, column): + column + } + } } } diff --git a/Sources/Parsing/Parser.Events.swift b/Sources/Parsing/Parser.Events.swift index 2e7a8d2..6f235fa 100644 --- a/Sources/Parsing/Parser.Events.swift +++ b/Sources/Parsing/Parser.Events.swift @@ -4,4 +4,36 @@ extension PureYAML.Parsing.Parser { var parser = PureYAML.Parsing.TokenEventParser(tokens: tokens, scalarParser: self) return try parser.parse() } + + func parseStreaming(_ yaml: String) throws -> PureYAML.Model.Value { + var composer = PureYAML.Parsing.EventStreamComposer( + scalarParser: self, + mode: .singleDocument, + ) + var parser = PureYAML.Parsing.TokenEventParser( + yaml: yaml, + scalarParser: self, + eventSink: { event in + try composer.consume(event) + }, + ) + try parser.parseStreaming() + return try composer.composedValue() + } + + func parseStreamStreaming(_ yaml: String) throws -> [PureYAML.Stream.Document] { + var composer = PureYAML.Parsing.EventStreamComposer( + scalarParser: self, + mode: .stream, + ) + var parser = PureYAML.Parsing.TokenEventParser( + yaml: yaml, + scalarParser: self, + eventSink: { event in + try composer.consume(event) + }, + ) + try parser.parseStreaming() + return try composer.composedStream() + } } diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift index 876c246..2c0d85f 100644 --- a/Sources/Parsing/Parser.swift +++ b/Sources/Parsing/Parser.swift @@ -4,15 +4,11 @@ public extension PureYAML.Parsing { public init() {} public func parse(_ yaml: String) throws -> PureYAML.Model.Value { - let events = try parseEvents(yaml) - var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: self) - return try composer.compose() + try parseStreaming(yaml) } public func parseStream(_ yaml: String) throws -> [PureYAML.Stream.Document] { - let events = try parseEvents(yaml) - var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: self) - return try composer.composeStream() + try parseStreamStreaming(yaml) } } } diff --git a/Sources/Parsing/Reader.swift b/Sources/Parsing/Reader.swift index 25ad45d..ea3a8b4 100644 --- a/Sources/Parsing/Reader.swift +++ b/Sources/Parsing/Reader.swift @@ -1,14 +1,18 @@ extension PureYAML.Parsing { struct Reader { private let source: String + private let utf8: String.UTF8View private var position: String.Index + private var utf8Position: String.UTF8View.Index private var line: Int private var column: Int private var utf8Index: Int init(_ source: String) { self.source = source + utf8 = source.utf8 position = source.startIndex + utf8Position = source.utf8.startIndex line = 1 column = 1 utf8Index = 0 @@ -42,22 +46,20 @@ extension PureYAML.Parsing { return nil } let character = source[position] - position = source.index(after: position) - utf8Index += String(character).utf8.count - if character == "\r\n" { + switch utf8[utf8Position] { + case 13: + advanceCarriageReturn() line += 1 column = 1 - } else if character == "\r" { - if position < source.endIndex, source[position] == "\n" { - position = source.index(after: position) - utf8Index += 1 - } + case 10: + advanceASCIICharacter() line += 1 column = 1 - } else if character == "\n" { - line += 1 - column = 1 - } else { + case 0 ..< 128: + advanceASCIICharacter() + column += 1 + default: + advanceExtendedCharacter() column += 1 } return character @@ -71,5 +73,35 @@ extension PureYAML.Parsing { } return output } + + private mutating func advanceASCIICharacter() { + position = source.index(after: position) + utf8Position = utf8.index(after: utf8Position) + utf8Index += 1 + } + + private mutating func advanceCarriageReturn() { + position = source.index(after: position) + utf8Position = utf8.index(after: utf8Position) + utf8Index += 1 + guard utf8Position < utf8.endIndex, utf8[utf8Position] == 10 else { + return + } + utf8Position = utf8.index(after: utf8Position) + utf8Index += 1 + if position < source.endIndex, source[position] == "\n" { + position = source.index(after: position) + } + } + + private mutating func advanceExtendedCharacter() { + position = source.index(after: position) + guard let nextUTF8Position = position.samePosition(in: utf8) else { + utf8Index += String(source[source.index(before: position)]).utf8.count + return + } + utf8Index += utf8.distance(from: utf8Position, to: nextUTF8Position) + utf8Position = nextUTF8Position + } } } diff --git a/Sources/Parsing/Scalar.swift b/Sources/Parsing/Scalar.swift index 74aa966..8d83522 100644 --- a/Sources/Parsing/Scalar.swift +++ b/Sources/Parsing/Scalar.swift @@ -137,35 +137,122 @@ extension PureYAML.Parsing.Parser { } let inner = text.dropFirst().dropLast() var output = "" - var escaping = false - for character in inner { - if escaping { - appendDoubleQuotedEscape(character, to: &output) - escaping = false - } else if character == "\\" { - escaping = true + var index = inner.startIndex + while index < inner.endIndex { + let character = inner[index] + if character == "\\" { + inner.formIndex(after: &index) + guard index < inner.endIndex else { + output.append("\\") + break + } + appendDoubleQuotedEscape(in: inner, index: &index, to: &output) } else { output.append(character) + inner.formIndex(after: &index) } } - if escaping { - output.append("\\") - } return output } func appendDoubleQuotedEscape( - _ character: Character, + in text: Substring, + index: inout Substring.Index, to output: inout String, ) { + let character = text[index] switch character { case "\"": output.append("\"") case "\\": output.append("\\") case "/": output.append("/") + case "b": output.append("\u{08}") + case "f": output.append("\u{0C}") case "n": output.append("\n") case "r": output.append("\r") case "t": output.append("\t") + case "u": + appendUnicodeEscape(in: text, index: &index, to: &output) default: output.append(character) } + text.formIndex(after: &index) + } + + func appendUnicodeEscape( + in text: Substring, + index: inout Substring.Index, + to output: inout String, + ) { + let escapeStart = index + guard let codeUnit = readUnicodeEscapeCodeUnit(in: text, index: &index) else { + output.append("u") + return + } + let surrogatePair = readSurrogatePairIfNeeded(high: codeUnit, in: text, index: &index) + if let scalar = surrogatePair { + output.unicodeScalars.append(scalar) + } else if let scalar = UnicodeScalar(codeUnit) { + output.unicodeScalars.append(scalar) + } else { + output += text[escapeStart ... index] + } + } + + func readSurrogatePairIfNeeded( + high: UInt32, + in text: Substring, + index: inout Substring.Index, + ) -> UnicodeScalar? { + guard isHighSurrogate(high) else { + return nil + } + return readSurrogatePair(high: high, in: text, index: &index) + } + + func readSurrogatePair( + high: UInt32, + in text: Substring, + index: inout Substring.Index, + ) -> UnicodeScalar? { + var probe = index + text.formIndex(after: &probe) + guard probe < text.endIndex, text[probe] == "\\" else { + return nil + } + text.formIndex(after: &probe) + guard probe < text.endIndex, text[probe] == "u", + let low = readUnicodeEscapeCodeUnit(in: text, index: &probe), + isLowSurrogate(low) + else { + return nil + } + index = probe + let value = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00) + return UnicodeScalar(value) + } + + func readUnicodeEscapeCodeUnit( + in text: Substring, + index: inout Substring.Index, + ) -> UInt32? { + var probe = index + text.formIndex(after: &probe) + var hex = "" + for _ in 0 ..< 4 { + guard probe < text.endIndex, text[probe].isHexDigit else { + return nil + } + hex.append(text[probe]) + text.formIndex(after: &probe) + } + index = text.index(before: probe) + return UInt32(hex, radix: 16) + } + + func isHighSurrogate(_ value: UInt32) -> Bool { + (0xD800 ... 0xDBFF).contains(value) + } + + func isLowSurrogate(_ value: UInt32) -> Bool { + (0xDC00 ... 0xDFFF).contains(value) } } diff --git a/Sources/Parsing/Scanner.FlowContext.swift b/Sources/Parsing/Scanner.FlowContext.swift new file mode 100644 index 0000000..7f22627 --- /dev/null +++ b/Sources/Parsing/Scanner.FlowContext.swift @@ -0,0 +1,11 @@ +extension PureYAML.Parsing { + enum ScannerFlowContextKind { + case sequence + case mapping + } + + struct ScannerFlowContext { + var kind: ScannerFlowContextKind + var isExpectingKey: Bool + } +} diff --git a/Sources/Parsing/Scanner.Helpers.swift b/Sources/Parsing/Scanner.Helpers.swift index 80b6f7a..9c02528 100644 --- a/Sources/Parsing/Scanner.Helpers.swift +++ b/Sources/Parsing/Scanner.Helpers.swift @@ -234,7 +234,8 @@ extension PureYAML.Parsing.Scanner { func shouldEndPlainScalarAtMappingValue(state: State) -> Bool { if state.flowDepth > 0 { - return isMappingValueBoundary(state.reader.peek(offset: 1)) + return state.isExpectingFlowMappingKey + || isMappingValueBoundary(state.reader.peek(offset: 1)) } return isBlockMappingValueBoundary(state.reader.peek(offset: 1)) } @@ -242,4 +243,16 @@ extension PureYAML.Parsing.Scanner { func isAtMappingValueIndicator(state: State) -> Bool { state.reader.peek() == ":" && shouldEndPlainScalarAtMappingValue(state: state) } + + @discardableResult + func appendMappingKeyIfNeeded( + start: PureYAML.Parsing.Mark, + state: inout State, + ) -> Bool { + guard isAtMappingValueIndicator(state: state) else { + return false + } + state.append(.mappingKey, mark: start, endMark: start) + return true + } } diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 0a8552f..d8c0542 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -47,6 +47,7 @@ extension PureYAML.Parsing.Scanner { var hasDocumentStartMarker = false var isDocumentClosed = false var flowDepth = 0 + var flowContexts: [PureYAML.Parsing.ScannerFlowContext] = [] var validImplicitIndentation: Set = [] var pendingBlockScalarIndentation = false var pendingBlockScalarIndentationIndicator: Int? @@ -80,6 +81,11 @@ extension PureYAML.Parsing.Scanner { hasDocumentContent = true } } + + var isExpectingFlowMappingKey: Bool { + flowContexts.last?.kind == .mapping + && flowContexts.last?.isExpectingKey == true + } } } @@ -123,7 +129,7 @@ extension PureYAML.Parsing.Scanner { case "?" where isIndicatorBoundary(state.reader.peek(offset: 1)): scanFixedToken(.mappingKey, state: &state) return true - case ":" where isMappingValueBoundary(state.reader.peek(offset: 1)): + case ":" where isAtMappingValueIndicator(state: state): scanFixedToken(.mappingValue, state: &state) return true default: @@ -138,22 +144,29 @@ extension PureYAML.Parsing.Scanner { switch character { case "[": state.flowDepth += 1 + state.flowContexts.append(.init(kind: .sequence, isExpectingKey: false)) scanFixedToken(.flowSequenceStart, state: &state) return true case "]": state.flowDepth = max(0, state.flowDepth - 1) scanFixedToken(.flowSequenceEnd, state: &state) + _ = state.flowContexts.popLast() return true case "{": state.flowDepth += 1 + state.flowContexts.append(.init(kind: .mapping, isExpectingKey: true)) scanFixedToken(.flowMappingStart, state: &state) return true case "}": state.flowDepth = max(0, state.flowDepth - 1) scanFixedToken(.flowMappingEnd, state: &state) + _ = state.flowContexts.popLast() return true case ",": scanFixedToken(.flowEntry, state: &state) + if state.flowContexts.last?.kind == .mapping { + state.flowContexts[state.flowContexts.count - 1].isExpectingKey = true + } return true default: return false @@ -225,6 +238,9 @@ extension PureYAML.Parsing.Scanner { if case .blockEntry = kind, hasNodeOnSameLine(afterCurrentPositionIn: state.reader) { state.validImplicitIndentation.insert(start.column + 1) } + if case .mappingValue = kind, state.flowContexts.last?.kind == .mapping { + state.flowContexts[state.flowContexts.count - 1].isExpectingKey = false + } } func scanComment(_ state: inout State) { @@ -371,16 +387,4 @@ extension PureYAML.Parsing.Scanner { } state.append(.scalar(value: trimmed, style: .plain), mark: start, endMark: state.reader.mark) } - - @discardableResult - func appendMappingKeyIfNeeded( - start: PureYAML.Parsing.Mark, - state: inout State, - ) -> Bool { - guard state.reader.peek() == ":", isMappingValueBoundary(state.reader.peek(offset: 1)) else { - return false - } - state.append(.mappingKey, mark: start, endMark: start) - return true - } } diff --git a/Sources/Parsing/TokenCursor.swift b/Sources/Parsing/TokenCursor.swift index deab88f..4331dbe 100644 --- a/Sources/Parsing/TokenCursor.swift +++ b/Sources/Parsing/TokenCursor.swift @@ -1,11 +1,36 @@ extension PureYAML.Parsing { + private final class TokenScannerStateBox { + var state: Scanner.State + + init(state: Scanner.State) { + self.state = state + } + } + struct TokenCursor { private var tokens: [Token] private(set) var index: Int + private var previousToken: Token? + private var scanner: Scanner? + private var scannerBox: TokenScannerStateBox? init(tokens: [Token]) { self.tokens = tokens.filter { !$0.kind.isComment } index = 0 + previousToken = nil + scanner = nil + scannerBox = nil + } + + init(yaml: String, scanner: Scanner = .init()) { + var state = Scanner.State(reader: Reader(yaml)) + state.append(.streamStart) + tokens = state.tokens.filter { !$0.kind.isComment } + state.tokens.removeAll(keepingCapacity: true) + index = 0 + previousToken = nil + self.scanner = scanner + scannerBox = TokenScannerStateBox(state: state) } var current: Token? { @@ -16,14 +41,11 @@ extension PureYAML.Parsing { } var previous: Token? { - let previousIndex = index - 1 - guard tokens.indices.contains(previousIndex) else { - return nil - } - return tokens[previousIndex] + previousToken } - func peek(offset: Int = 1) -> Token? { + mutating func peek(offset: Int = 1) throws -> Token? { + try ensureAvailable(offset: offset) let targetIndex = index + offset guard tokens.indices.contains(targetIndex) else { return nil @@ -31,13 +53,80 @@ extension PureYAML.Parsing { return tokens[targetIndex] } - mutating func advance() -> Token? { - guard let token = current else { + mutating func advance() throws -> Token? { + try ensureAvailable(offset: 0) + guard tokens.indices.contains(index) else { return nil } + let token = tokens[index] + previousToken = token index += 1 + compactConsumedTokens() + try ensureAvailable(offset: 0) return token } + + private mutating func ensureAvailable(offset: Int) throws { + while tokens.count <= index + offset, scannerBox != nil { + try scanMoreTokens() + } + } + + private mutating func scanMoreTokens() throws { + guard let scanner, let scannerBox else { + return + } + + while scannerBox.state.tokens.isEmpty { + guard !scannerBox.state.reader.isAtEnd else { + scanner.closeIndentation(&scannerBox.state) + scannerBox.state.append(.streamEnd) + drainScannedTokens(from: &scannerBox.state) + self.scannerBox = nil + return + } + + if scanner.isLineBreak(scannerBox.state.reader.peek()) { + scannerBox.state.reader.advance() + scannerBox.state.isAtLineStart = true + continue + } + + if scannerBox.state.isAtLineStart { + try scanner.scanIndentation(&scannerBox.state) + if scannerBox.state.reader.isAtEnd { + continue + } + if scanner.isLineBreak(scannerBox.state.reader.peek()) { + continue + } + if try scanner.scanDirectiveOrDocumentMarker(&scannerBox.state) { + if !scannerBox.state.tokens.isEmpty { + break + } + continue + } + scannerBox.state.isAtLineStart = false + } + + try scanner.scanToken(&scannerBox.state) + } + + drainScannedTokens(from: &scannerBox.state) + } + + private mutating func drainScannedTokens(from state: inout Scanner.State) { + tokens.append(contentsOf: state.tokens.filter { !$0.kind.isComment }) + state.tokens.removeAll(keepingCapacity: true) + } + + private mutating func compactConsumedTokens() { + guard index > 4096 else { + return + } + tokens.removeFirst(index) + index = 0 + } } } diff --git a/Sources/Parsing/TokenEventParser.Block.swift b/Sources/Parsing/TokenEventParser.Block.swift index 7b028c6..363b3a0 100644 --- a/Sources/Parsing/TokenEventParser.Block.swift +++ b/Sources/Parsing/TokenEventParser.Block.swift @@ -6,7 +6,7 @@ extension PureYAML.Parsing.TokenEventParser { let start = try expect("block sequence entry") { $0.isBlockEntry } let startMark = properties.mark ?? start.mark var endMark = start.endMark - events.append(.sequenceStart( + try emit(.sequenceStart( anchor: properties.anchor, tag: properties.tag, style: .block, @@ -34,13 +34,13 @@ extension PureYAML.Parsing.TokenEventParser { throw PureYAML.Parsing.ParseError.mixedCollectionStyles(line: current.mark.line) } - events.append(.sequenceEnd(mark: endMark)) + try emit(.sequenceEnd(mark: endMark)) return PureYAML.Parsing.NodeResult(kind: .sequence, endMark: endMark) } mutating func consumeSequenceItemBoundaryDedent(sequenceIndentWidth: Int) throws { guard dedentWidth(cursor.current) == sequenceIndentWidth, - cursor.peek()?.kind.isBlockEntry == true + try cursor.peek()?.kind.isBlockEntry == true else { return } @@ -49,7 +49,7 @@ extension PureYAML.Parsing.TokenEventParser { mutating func consumeSequenceItemBoundaryIndent(sequenceIndentWidth: Int) throws { guard indentWidth(cursor.current) == sequenceIndentWidth, - cursor.peek()?.kind.isBlockEntry == true + try cursor.peek()?.kind.isBlockEntry == true else { return } @@ -62,7 +62,7 @@ extension PureYAML.Parsing.TokenEventParser { allowMappingKeyTerminator: Bool, ) throws { if hasNodeOnSameLine(after: entry) { - let properties = consumeProperties() + let properties = try consumeProperties() if cursor.current?.kind.isIndent == true { _ = try expect("indent") { $0.isIndent } endMark = try parseNode(properties: properties).endMark @@ -89,7 +89,7 @@ extension PureYAML.Parsing.TokenEventParser { _ = try expect("dedent") { $0.isDedent } } else { let mark = entry.endMark - events.append(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) endMark = mark } } @@ -104,7 +104,7 @@ extension PureYAML.Parsing.TokenEventParser { let contentColumn = min(startMark.column, tokenColumn) let boundaryColumn = minimumColumn ?? contentColumn var endMark = startMark - events.append(.mappingStart( + try emit(.mappingStart( anchor: properties.anchor, tag: properties.tag, style: .block, @@ -124,7 +124,7 @@ extension PureYAML.Parsing.TokenEventParser { column: current.mark.column, ) } - events.append(.mappingEnd(mark: endMark)) + try emit(.mappingEnd(mark: endMark)) return PureYAML.Parsing.NodeResult(kind: .mapping, endMark: endMark) } @@ -196,7 +196,7 @@ extension PureYAML.Parsing.TokenEventParser { guard case let .scalar(value, style) = cursor.current?.kind else { throw unexpectedToken(expected: "block scalar content") } - let token = cursor.advance() ?? header + let token = try cursor.advance() ?? header try lines.append(decodeScalar(value, style: style, line: token.mark.line)) endMark = token.endMark } @@ -205,7 +205,7 @@ extension PureYAML.Parsing.TokenEventParser { let value = blockScalarValue(lines: lines, header: header.kind) let mark = properties.mark ?? header.mark - events.append(.scalar( + try emit(.scalar( value: value, anchor: properties.anchor, tag: properties.tag, diff --git a/Sources/Parsing/TokenEventParser.Flow.swift b/Sources/Parsing/TokenEventParser.Flow.swift index c90c3bb..6291315 100644 --- a/Sources/Parsing/TokenEventParser.Flow.swift +++ b/Sources/Parsing/TokenEventParser.Flow.swift @@ -10,7 +10,7 @@ extension PureYAML.Parsing.TokenEventParser { } let startMark = properties.mark ?? start.mark var endMark = start.endMark - events.append(.sequenceStart( + try emit(.sequenceStart( anchor: properties.anchor, tag: properties.tag, style: .flow, @@ -25,11 +25,11 @@ extension PureYAML.Parsing.TokenEventParser { return false } endMark = end.endMark - events.append(.sequenceEnd(mark: endMark)) + try emit(.sequenceEnd(mark: endMark)) return PureYAML.Parsing.NodeResult(kind: .sequence, endMark: endMark) } if case .flowEntry = cursor.current?.kind { - _ = cursor.advance() + _ = try cursor.advance() continue } endMark = try parseNode().endMark @@ -51,7 +51,7 @@ extension PureYAML.Parsing.TokenEventParser { } let startMark = properties.mark ?? start.mark var endMark = start.endMark - events.append(.mappingStart( + try emit(.mappingStart( anchor: properties.anchor, tag: properties.tag, style: .flow, @@ -66,11 +66,11 @@ extension PureYAML.Parsing.TokenEventParser { return false } endMark = end.endMark - events.append(.mappingEnd(mark: endMark)) + try emit(.mappingEnd(mark: endMark)) return PureYAML.Parsing.NodeResult(kind: .mapping, endMark: endMark) } if case .flowEntry = cursor.current?.kind { - _ = cursor.advance() + _ = try cursor.advance() continue } endMark = try parseFlowMappingPair().endMark @@ -94,10 +94,10 @@ extension PureYAML.Parsing.TokenEventParser { return false } - let properties = consumeProperties() + let properties = try consumeProperties() guard properties != .none || cursor.current?.kind.isFlowTerminator == false else { let mark = valueIndicator.endMark - events.append(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark) } return try parseNode(properties: properties) diff --git a/Sources/Parsing/TokenEventParser.Helpers.swift b/Sources/Parsing/TokenEventParser.Helpers.swift index b20cbac..dea4413 100644 --- a/Sources/Parsing/TokenEventParser.Helpers.swift +++ b/Sources/Parsing/TokenEventParser.Helpers.swift @@ -9,7 +9,7 @@ extension PureYAML.Parsing.TokenEventParser { return false } - let properties = consumeProperties() + let properties = try consumeProperties() if cursor.current?.kind.isIndent == true { _ = try expect("indent") { $0.isIndent } let result: PureYAML.Parsing.NodeResult = if cursor.current?.kind.isBlockEntry == true { @@ -27,7 +27,7 @@ extension PureYAML.Parsing.TokenEventParser { } return result } - if properties == .none, canParsePlainScalarWithContinuation(after: valueIndicator) { + if properties == .none, try canParsePlainScalarWithContinuation(after: valueIndicator) { return try parsePlainScalarWithContinuation(properties: properties) } if cursor.current?.kind.isMappingKey == true, hasNodeOnSameLine(after: valueIndicator) { @@ -48,7 +48,7 @@ extension PureYAML.Parsing.TokenEventParser { } let mark = valueIndicator.endMark - events.append(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark) } @@ -66,7 +66,7 @@ extension PureYAML.Parsing.TokenEventParser { _ = try expect("indent") { $0.isIndent } while isScalar(cursor.current) { - let token = cursor.advance() ?? first + let token = try cursor.advance() ?? first let continuation = try plainContinuationText(from: token) value += " " + continuation endMark = token.endMark @@ -74,7 +74,7 @@ extension PureYAML.Parsing.TokenEventParser { _ = try expect("dedent") { $0.isDedent } let mark = properties.mark ?? first.mark - events.append(.scalar( + try emit(.scalar( value: value, anchor: properties.anchor, tag: properties.tag, @@ -84,18 +84,18 @@ extension PureYAML.Parsing.TokenEventParser { return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: endMark) } - mutating func consumeProperties() -> PureYAML.Parsing.NodeProperties { + mutating func consumeProperties() throws -> PureYAML.Parsing.NodeProperties { var properties = PureYAML.Parsing.NodeProperties.none while let token = cursor.current { switch token.kind { case let .anchor(anchor): properties.anchor = anchor properties.mark = properties.mark ?? token.mark - _ = cursor.advance() + _ = try cursor.advance() case let .tag(tag): properties.tag = tag properties.mark = properties.mark ?? token.mark - _ = cursor.advance() + _ = try cursor.advance() default: return properties } @@ -148,30 +148,30 @@ extension PureYAML.Parsing.TokenEventParser { return current.mark.line == token.mark.line } - func canParsePlainScalarWithContinuation(after token: PureYAML.Parsing.Token) -> Bool { + mutating func canParsePlainScalarWithContinuation(after token: PureYAML.Parsing.Token) throws -> Bool { guard case .scalar(_, .plain) = cursor.current?.kind else { return false } guard cursor.current?.mark.line == token.mark.line else { return false } - guard cursor.peek()?.kind.isIndent == true else { + guard try cursor.peek()?.kind.isIndent == true else { return false } - guard isScalar(cursor.peek(offset: 2)) else { + guard try isScalar(cursor.peek(offset: 2)) else { return false } return true } - func canParsePlainScalarContinuation() -> Bool { + mutating func canParsePlainScalarContinuation() throws -> Bool { guard case .scalar(_, .plain) = cursor.current?.kind else { return false } - guard cursor.peek()?.kind.isIndent == true else { + guard try cursor.peek()?.kind.isIndent == true else { return false } - guard isScalar(cursor.peek(offset: 2)) else { + guard try isScalar(cursor.peek(offset: 2)) else { return false } return true @@ -191,14 +191,14 @@ extension PureYAML.Parsing.TokenEventParser { } mutating func consumeMappingSiblingBoundaryDedents(minimumColumn: Int) throws { - while canConsumeMappingSiblingBoundaryDedent(minimumColumn: minimumColumn) { + while try canConsumeMappingSiblingBoundaryDedent(minimumColumn: minimumColumn) { _ = try expect("dedent") { $0.isDedent } } } - func canConsumeMappingSiblingBoundaryDedent(minimumColumn: Int) -> Bool { + mutating func canConsumeMappingSiblingBoundaryDedent(minimumColumn: Int) throws -> Bool { guard let width = dedentWidth(cursor.current), - let next = cursor.peek(), + let next = try cursor.peek(), next.kind.isMappingKey, next.mark.column >= minimumColumn else { @@ -210,8 +210,8 @@ extension PureYAML.Parsing.TokenEventParser { mutating func consumeMappingSiblingBoundaryReindent(minimumColumn: Int) throws { guard let dedent = dedentWidth(cursor.current), dedent < minimumColumn, - indentWidth(cursor.peek()) == minimumColumn, - let mappingKey = cursor.peek(offset: 2), + try indentWidth(cursor.peek()) == minimumColumn, + let mappingKey = try cursor.peek(offset: 2), mappingKey.kind.isMappingKey, mappingKey.mark.column == minimumColumn + 1 else { @@ -292,7 +292,7 @@ extension PureYAML.Parsing.TokenEventParser { column: token.mark.column, ) } - _ = cursor.advance() + _ = try cursor.advance() return token } diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index c9c7607..5fd2a95 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -2,7 +2,8 @@ extension PureYAML.Parsing { struct TokenEventParser { var cursor: TokenCursor let scalarParser: Parser - var events: [Event] + var events: [Event]? + var eventSink: ((Event) throws -> Void)? init( tokens: [Token], @@ -11,9 +12,41 @@ extension PureYAML.Parsing { cursor = TokenCursor(tokens: tokens) self.scalarParser = scalarParser events = [] + eventSink = nil } mutating func parse() throws -> [Event] { + try parseEvents() + return events ?? [] + } + + init( + tokens: [Token], + scalarParser: Parser, + eventSink: @escaping (Event) throws -> Void, + ) { + cursor = TokenCursor(tokens: tokens) + self.scalarParser = scalarParser + events = nil + self.eventSink = eventSink + } + + init( + yaml: String, + scalarParser: Parser, + eventSink: @escaping (Event) throws -> Void, + ) { + cursor = TokenCursor(yaml: yaml) + self.scalarParser = scalarParser + events = nil + self.eventSink = eventSink + } + + mutating func parseStreaming() throws { + try parseEvents() + } + + mutating func parseEvents() throws { let streamStart = try expect("stream start") { kind in if case .streamStart = kind { return true @@ -21,7 +54,7 @@ extension PureYAML.Parsing { return false } - events.append(.streamStart(mark: streamStart.mark)) + try emit(.streamStart(mark: streamStart.mark)) var didParseDocument = false while cursor.current?.kind.isStreamEnd != true { try parseDocument() @@ -29,9 +62,9 @@ extension PureYAML.Parsing { } if !didParseDocument { let mark = cursor.current?.mark ?? streamStart.endMark - events.append(.documentStart(mark: mark)) - appendEmptyDocument(at: mark) - events.append(.documentEnd(mark: mark)) + try emit(.documentStart(mark: mark)) + try appendEmptyDocument(at: mark) + try emit(.documentEnd(mark: mark)) } let streamEnd = try expect("stream end") { kind in if case .streamEnd = kind { @@ -39,27 +72,34 @@ extension PureYAML.Parsing { } return false } - events.append(.streamEnd(mark: streamEnd.mark)) - return events + try emit(.streamEnd(mark: streamEnd.mark)) + } + + mutating func emit(_ event: Event) throws { + if let eventSink { + try eventSink(event) + } else { + events?.append(event) + } } } } extension PureYAML.Parsing.TokenEventParser { mutating func parseDocument() throws { - let startMark = parseDocumentStartMark() - events.append(.documentStart(mark: startMark)) + let startMark = try parseDocumentStartMark() + try emit(.documentStart(mark: startMark)) if cursor.current?.kind.isDocumentEnd == true { let end = try expect("document end") { $0.isDocumentEnd } - appendEmptyDocument(at: end.mark) - events.append(.documentEnd(mark: end.endMark)) + try appendEmptyDocument(at: end.mark) + try emit(.documentEnd(mark: end.endMark)) return } if cursor.current?.kind.isDocumentStart == true || cursor.current?.kind.isStreamEnd == true { - appendEmptyDocument(at: cursor.current?.mark ?? startMark) - events.append(.documentEnd(mark: cursor.current?.mark ?? startMark)) + try appendEmptyDocument(at: cursor.current?.mark ?? startMark) + try emit(.documentEnd(mark: cursor.current?.mark ?? startMark)) return } @@ -80,7 +120,7 @@ extension PureYAML.Parsing.TokenEventParser { column: current.mark.column, ) } - events.append(.documentEnd(mark: endMark)) + try emit(.documentEnd(mark: endMark)) } mutating func parseDocumentRootNode() throws -> PureYAML.Parsing.NodeResult { @@ -95,16 +135,16 @@ extension PureYAML.Parsing.TokenEventParser { return result } - mutating func parseDocumentStartMark() -> PureYAML.Parsing.Mark { + mutating func parseDocumentStartMark() throws -> PureYAML.Parsing.Mark { guard cursor.current?.kind.isDocumentStart == true else { return cursor.current?.mark ?? cursor.previous?.endMark ?? .start } - let start = cursor.advance() + let start = try cursor.advance() return start?.mark ?? .start } - mutating func appendEmptyDocument(at mark: PureYAML.Parsing.Mark) { - events.append(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + mutating func appendEmptyDocument(at mark: PureYAML.Parsing.Mark) throws { + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) } func canEndDocument(at token: PureYAML.Parsing.Token) -> Bool { @@ -112,7 +152,7 @@ extension PureYAML.Parsing.TokenEventParser { } mutating func parseNode() throws -> PureYAML.Parsing.NodeResult { - let properties = consumeProperties() + let properties = try consumeProperties() return try parseNode(properties: properties) } @@ -120,7 +160,7 @@ extension PureYAML.Parsing.TokenEventParser { properties initialProperties: PureYAML.Parsing.NodeProperties, ) throws -> PureYAML.Parsing.NodeResult { var properties = initialProperties - properties.mergeUnset(from: consumeProperties()) + try properties.mergeUnset(from: consumeProperties()) guard let token = cursor.current, !token.kind.isTerminator else { let mark = properties.mark ?? cursor.current?.mark ?? cursor.previous?.endMark ?? .start @@ -129,9 +169,9 @@ extension PureYAML.Parsing.TokenEventParser { switch token.kind { case let .alias(anchor): - _ = cursor.advance() + _ = try cursor.advance() let mark = properties.mark ?? token.mark - events.append(.alias(anchor: anchor, mark: mark)) + try emit(.alias(anchor: anchor, mark: mark)) return PureYAML.Parsing.NodeResult(kind: .alias, endMark: token.endMark) case .blockEntry: return try parseBlockSequence(properties: properties) @@ -147,13 +187,13 @@ extension PureYAML.Parsing.TokenEventParser { includeIndentedContinuation: false, ) case let .scalar(value, style): - if canParsePlainScalarContinuation() { + if try canParsePlainScalarContinuation() { return try parsePlainScalarWithContinuation(properties: properties) } - _ = cursor.advance() + _ = try cursor.advance() let decoded = try decodeScalar(value, style: style, line: token.mark.line) let mark = properties.mark ?? token.mark - events.append(.scalar( + try emit(.scalar( value: decoded, anchor: properties.anchor, tag: properties.tag, diff --git a/Sources/Validation/DiagnosticValidation.swift b/Sources/Validation/DiagnosticValidation.swift index d5d30d3..f0569ee 100644 --- a/Sources/Validation/DiagnosticValidation.swift +++ b/Sources/Validation/DiagnosticValidation.swift @@ -88,7 +88,7 @@ private extension PureYAML { var diagnostics = preflightScanner.diagnostics(in: yaml, file: file) do { - let documents = try parseStream(yaml) + let documents = try parseStreamForDiagnostics(yaml) let result = validator.collect(documents) diagnostics.append(contentsOf: result.issues.map { streamIssue in Validation.Diagnostic( @@ -107,10 +107,11 @@ private extension PureYAML { } catch let error as Parsing.ParseError { diagnostics.append(Validation.Diagnostic( kind: .parse, + code: error.diagnosticCode, severity: .error, file: file, - line: error.line, - column: error.column, + line: error.sourceLine, + column: error.sourceColumn, reason: error.description, )) return DiagnosticValidationResult( @@ -130,59 +131,11 @@ private extension PureYAML { ) } } -} -private extension PureYAML.Parsing.ParseError { - var line: Int? { - switch self { - case .emptyDocument: - nil - case let .tabIndentation(line), - let .unexpectedIndentation(line), - let .mixedCollectionStyles(line), - let .expectedMappingKey(line), - let .expectedAnchorName(line), - let .expectedAliasName(line), - let .incompatibleYAMLDirective(line), - let .unterminatedTag(line), - let .unterminatedQuotedString(line), - let .unsupportedDirective(_, line), - let .unsupportedMultiDocumentStream(line): - line - case let .expectedNode(line, _), - let .expectedScalarKey(line, _), - let .invalidTaggedScalar(_, _, line, _), - let .invalidMergeValue(line, _), - let .undefinedAlias(_, line, _), - let .unexpectedEvent(_, _, line, _), - let .unexpectedToken(_, _, line, _): - line - } - } - - var column: Int? { - switch self { - case .emptyDocument, - .tabIndentation, - .unexpectedIndentation, - .mixedCollectionStyles, - .expectedMappingKey, - .expectedAnchorName, - .expectedAliasName, - .incompatibleYAMLDirective, - .unterminatedTag, - .unterminatedQuotedString, - .unsupportedDirective, - .unsupportedMultiDocumentStream: - nil - case let .expectedNode(_, column), - let .expectedScalarKey(_, column), - let .invalidTaggedScalar(_, _, _, column), - let .invalidMergeValue(_, column), - let .undefinedAlias(_, _, column), - let .unexpectedEvent(_, _, _, column), - let .unexpectedToken(_, _, _, column): - column - } + static func parseStreamForDiagnostics(_ yaml: String) throws -> [Stream.Document] { + let parser = Parsing.Parser() + let events = try parser.parseEvents(yaml) + var composer = Parsing.EventComposer(events: events, scalarParser: parser) + return try composer.composeStream() } } diff --git a/Sources/Validation/PreflightScanner.swift b/Sources/Validation/PreflightScanner.swift index 12f01ca..220d683 100644 --- a/Sources/Validation/PreflightScanner.swift +++ b/Sources/Validation/PreflightScanner.swift @@ -26,6 +26,7 @@ public extension PureYAML.Validation { var diagnostics: [Diagnostic] = [] if let column = firstIndentationTabColumn(in: line) { diagnostics.append(.preflight( + code: "tabIndentation", file: file, line: lineNumber, column: column, @@ -34,6 +35,7 @@ public extension PureYAML.Validation { } if let column = missingMappingSpaceColumn(in: line) { diagnostics.append(.preflight( + code: "missingMappingSpace", file: file, line: lineNumber, column: column, @@ -42,6 +44,7 @@ public extension PureYAML.Validation { } if let column = missingSequenceSpaceColumn(in: line) { diagnostics.append(.preflight( + code: "missingSequenceSpace", file: file, line: lineNumber, column: column, @@ -50,6 +53,7 @@ public extension PureYAML.Validation { } if let column = invalidControlCharacterColumn(in: line) { diagnostics.append(.preflight( + code: "invalidControlCharacter", file: file, line: lineNumber, column: column, @@ -58,6 +62,7 @@ public extension PureYAML.Validation { } if let column = trailingWhitespaceColumn(in: line) { diagnostics.append(.preflight( + code: "trailingWhitespace", file: file, line: lineNumber, column: column, @@ -72,6 +77,7 @@ public extension PureYAML.Validation { private extension PureYAML.Validation.Diagnostic { static func preflight( + code: String, file: String?, line: Int, column: Int, @@ -80,6 +86,7 @@ private extension PureYAML.Validation.Diagnostic { ) -> Self { .init( kind: .parse, + code: code, severity: severity, file: file, line: line, diff --git a/Sources/Validation/Validation.Diagnostic.swift b/Sources/Validation/Validation.Diagnostic.swift index 5782b0e..82f16d4 100644 --- a/Sources/Validation/Validation.Diagnostic.swift +++ b/Sources/Validation/Validation.Diagnostic.swift @@ -16,6 +16,7 @@ public extension PureYAML.Validation { /// A parse or validation diagnostic suitable for batch validation reports. struct Diagnostic: Equatable, Sendable, CustomStringConvertible { public var kind: DiagnosticKind + public var code: String? public var severity: Severity public var file: String? public var line: Int? @@ -26,6 +27,7 @@ public extension PureYAML.Validation { public init( kind: DiagnosticKind, + code: String? = nil, severity: Severity, file: String? = nil, line: Int? = nil, @@ -35,6 +37,7 @@ public extension PureYAML.Validation { reason: String, ) { self.kind = kind + self.code = code self.severity = severity self.file = file self.line = line diff --git a/Sources/Validation/Validation.Report.swift b/Sources/Validation/Validation.Report.swift index f160e37..71370d5 100644 --- a/Sources/Validation/Validation.Report.swift +++ b/Sources/Validation/Validation.Report.swift @@ -218,6 +218,9 @@ private extension PureYAML.Validation.Diagnostic { .init(key: "severity", value: .string(severity.description)), .init(key: "file", value: file.map(PureYAML.Model.Value.string) ?? .null), ] + if let code { + pairs.insert(.init(key: "code", value: .string(code)), at: 1) + } if let line { pairs.append(.init(key: "line", value: .int(line))) } @@ -352,6 +355,18 @@ public extension PureYAML { ) } return Validation.Report(diagnostics) + } catch let error as PureYAML.Parsing.ParseError { + return Validation.Report([ + Validation.Diagnostic( + kind: .parse, + code: error.diagnosticCode, + severity: .error, + file: file, + line: error.sourceLine, + column: error.sourceColumn, + reason: error.description, + ), + ]) } catch { return Validation.Report([ Validation.Diagnostic( diff --git a/Tests/ACMEZephyrFixtureTests.swift b/Tests/ACMEZephyrFixtureTests.swift new file mode 100644 index 0000000..6acaf77 --- /dev/null +++ b/Tests/ACMEZephyrFixtureTests.swift @@ -0,0 +1,145 @@ +import Foundation +@testable import PureYAML +import Testing + +@Suite("ACME Fixtures") +struct ACMEZephyrFixtureTests { + @Test("Parses Stitcher-style ACME Zephyr source files") + func test_parsesStitcherStyleACMEZephyrSourceFiles() throws { + let spec = try loadFixture("spec.yml", subdirectory: "Fixtures/acme/zephyr-data") + let activeFeature = try loadFixture( + "activeZorplexFeature.yml", + subdirectory: "Fixtures/acme/zephyr-data/schemas", + ) + let featureEnum = try loadFixture("zorplexFeature.yml", subdirectory: "Fixtures/acme/zephyr-data/schemas") + + let specRoot = try requireMapping(PureYAML.parse(spec)) + #expect(specRoot["openapi"] == .string("3.0.3")) + + let schemas = expectMapping(expectMapping(specRoot["components"])?["schemas"]) + #expect(expectMapping(schemas?["ActiveZorplexFeature"])?["$ref"] == .string("./schemas/activeZorplexFeature.yml")) + #expect(expectMapping(schemas?["ZorplexFeature"])?["$ref"] == .string("./schemas/zorplexFeature.yml")) + + let activeRoot = try requireMapping(PureYAML.parse(activeFeature)) + #expect(activeRoot["type"] == .string("object")) + #expect(requireSequence(activeRoot["required"])?.contains(.string("createdBy")) == true) + let properties = expectMapping(activeRoot["properties"]) + #expect(expectMapping(properties?["feature"])?["$ref"] == .string("./zorplexFeature.yml")) + #expect(expectMapping(properties?["deletedAt"])?["nullable"] == .bool(true)) + + let featureRoot = try requireMapping(PureYAML.parse(featureEnum)) + #expect(featureRoot["type"] == .string("string")) + #expect(requireSequence(featureRoot["enum"])?.contains(.string("QUASAR_MATRIX_V2")) == true) + } + + @Test("Parses bundled ACME Zephyr output") + func test_parsesBundledACMEZephyrOutput() throws { + let bundled = try loadFixture("bundled.yml", subdirectory: "Fixtures/acme/zephyr-data") + let root = try requireMapping(PureYAML.parse(bundled)) + + #expect(root["openapi"] == .string("3.0.3")) + let paths = expectMapping(root["paths"]) + let operation = expectMapping(expectMapping(paths?["/activeZorplexFeatures"])?["get"]) + #expect(operation?["operationId"] == .string("getActiveZorplexFeatures")) + + let components = expectMapping(root["components"]) + let schemas = expectMapping(components?["schemas"]) + let active = expectMapping(schemas?["ActiveZorplexFeature"]) + let properties = expectMapping(active?["properties"]) + let feature = expectMapping(properties?["feature"]) + #expect(requireSequence(feature?["enum"])?.contains(.string("BLARF_OPTIMIZATION")) == true) + #expect(expectMapping(properties?["deletedAt"])?["nullable"] == .bool(true)) + } + + @Test("Parses renamed Birch-style ACME catalog representative files") + func test_parsesRenamedBirchStyleACMECatalogRepresentativeFiles() throws { + let eventSpec = try loadFixture("spec.yml", subdirectory: "Fixtures/acme/birch-catalog/ui-event") + let originatorSpec = try loadFixture("spec.yml", subdirectory: "Fixtures/acme/birch-catalog/originator") + let originatorOffer = try loadFixture("offer.yml", subdirectory: "Fixtures/acme/birch-catalog/originator/schemas") + let apiError = try loadFixture("apiError.yml", subdirectory: "Fixtures/acme/birch-catalog/core/schemas") + + let eventRoot = try requireMapping(PureYAML.parse(eventSpec)) + #expect(eventRoot["openapi"] == .string("3.0.3")) + let eventSecurity = expectMapping(expectMapping(eventRoot["components"])?["securitySchemes"]) + let oauth = expectMapping(eventSecurity?["oauth"]) + let scopes = expectMapping(expectMapping(expectMapping(oauth?["flows"])?["clientCredentials"])?["scopes"]) + #expect(scopes?["ui-event:write"] == .string("Allows recording UI events")) + + let eventSchemas = expectMapping(expectMapping(eventRoot["components"])?["schemas"]) + #expect(expectMapping(eventSchemas?["ApiError"])?["$ref"] == .string("../core/schemas/apiError.yml")) + #expect(expectMapping(eventSchemas?["SessionInit"])?["$ref"] == .string("./schemas/sessionInit.yml")) + let offerDisplays = expectMapping(expectMapping(eventRoot["paths"])?["/offerDisplays"]) + let createOperation = expectMapping(offerDisplays?["post"]) + #expect(expectMapping(createOperation?["requestBody"])?["$ref"] == .string("./request-bodies/offerDisplay.yml")) + + let originatorRoot = try requireMapping(PureYAML.parse(originatorSpec)) + let originatorSchemas = expectMapping(expectMapping(originatorRoot["components"])?["schemas"]) + #expect(expectMapping(originatorSchemas?["ApiError"])?["$ref"] == .string("../core/schemas/apiError.yml")) + #expect(expectMapping(originatorSchemas?["CreditCardOfferRecord"])?["$ref"] == .string("./schemas/creditCardOfferRecord.yml")) + let originatorSecurity = expectMapping(expectMapping(originatorRoot["components"])?["securitySchemes"]) + let originatorOAuth = expectMapping(originatorSecurity?["oauth"]) + let originatorClientCredentials = expectMapping(expectMapping(originatorOAuth?["flows"])?["clientCredentials"]) + let originatorScopes = expectMapping(originatorClientCredentials?["scopes"]) + #expect(originatorScopes?["originator:admin:write"] == .string("Allows write access to all admin endpoints in originator")) + + let offerRoot = try requireMapping(PureYAML.parse(originatorOffer)) + let offerProperties = expectMapping(offerRoot["properties"]) + #expect(expectMapping(offerProperties?["partner"])?["$ref"] == .string("./demandPartner.yml")) + #expect(expectMapping(offerProperties?["productType"])?["$ref"] == .string("../../core/schemas/productType.yml")) + #expect(expectMapping(offerProperties?["productSubType"])?["$ref"] == .string("../../lead/schemas/productSubType.yml")) + + let errorRoot = try requireMapping(PureYAML.parse(apiError)) + let errorProperties = expectMapping(errorRoot["properties"]) + #expect(expectMapping(errorProperties?["details"])?["$ref"] == .string("./apiErrorDetails.yml")) + #expect(expectMapping(errorProperties?["message"])?["readOnly"] == .bool(true)) + } + + @Test("Parses all renamed Birch-style ACME catalog YAML files") + func test_parsesAllRenamedBirchStyleACMECatalogYAMLFiles() throws { + let rootURL = try #require(Bundle.module.url( + forResource: "birch-catalog", + withExtension: nil, + subdirectory: "Fixtures/acme", + )) + let files = try FileManager.default + .subpathsOfDirectory(atPath: rootURL.path) + .filter { $0.hasSuffix(".yml") || $0.hasSuffix(".yaml") } + .sorted() + #expect(files.count == 594) + + for file in files { + let url = rootURL.appendingPathComponent(file) + let source = try String(contentsOf: url, encoding: .utf8) + _ = try PureYAML.parse(source) + } + } + + private func loadFixture(_ name: String, subdirectory: String) throws -> String { + let url = try #require(Bundle.module.url(forResource: name, withExtension: nil, subdirectory: subdirectory)) + return try String(contentsOf: url, encoding: .utf8) + } + + private func requireMapping(_ value: PureYAML.Model.Value) throws -> PureYAML.Model.Mapping { + guard case let .mapping(mapping) = value else { + recordIssue("expected mapping") + return .init() + } + return mapping + } + + private func expectMapping(_ value: PureYAML.Model.Value?) -> PureYAML.Model.Mapping? { + guard case let .mapping(mapping)? = value else { + recordIssue("expected mapping") + return nil + } + return mapping + } + + private func requireSequence(_ value: PureYAML.Model.Value?) -> [PureYAML.Model.Value]? { + guard case let .sequence(sequence)? = value else { + recordIssue("expected sequence") + return nil + } + return sequence + } +} diff --git a/Tests/DiagnosticValidationReportTests.swift b/Tests/DiagnosticValidationReportTests.swift index 5abb2bc..7300677 100644 --- a/Tests/DiagnosticValidationReportTests.swift +++ b/Tests/DiagnosticValidationReportTests.swift @@ -37,6 +37,7 @@ struct DiagnosticValidationReportTests { #expect(report.diagnostics == [ .init( kind: .parse, + code: "trailingWhitespace", severity: .warning, file: "duplicates.yaml", line: 3, @@ -71,6 +72,7 @@ struct DiagnosticValidationReportTests { #expect(report.diagnostics == [ .init( kind: .parse, + code: "missingMappingSpace", severity: .error, file: "broken.yaml", line: 2, @@ -79,6 +81,7 @@ struct DiagnosticValidationReportTests { ), .init( kind: .parse, + code: "tabIndentation", severity: .error, file: "broken.yaml", line: 3, @@ -87,6 +90,7 @@ struct DiagnosticValidationReportTests { ), .init( kind: .parse, + code: "unterminatedQuotedString", severity: .error, file: "broken.yaml", line: 1, @@ -118,6 +122,7 @@ struct DiagnosticValidationReportTests { #expect(error.report.diagnostics.count == 3) #expect(json.contains(#""title": "Production YAML Validation""#)) + #expect(json.contains(#""code": "missingMappingSpace""#)) #expect(json.contains(#""line": 2"#)) #expect(json.contains(#""column": 9"#)) #expect(json.contains(#""reason": "missing space after ':' in mapping entry""#)) @@ -131,6 +136,7 @@ struct DiagnosticValidationReportTests { #expect(yamlRoot?["summary"]?.mapping?["diagnostics"] == PureYAML.Model.Value.int(3)) #expect(yamlRoot?["summary"]?.mapping?["errors"] == PureYAML.Model.Value.int(3)) #expect(yamlRoot?["summary"]?.mapping?["warnings"] == PureYAML.Model.Value.int(0)) + #expect(firstDiagnostic?["code"] == PureYAML.Model.Value.string("missingMappingSpace")) #expect(firstDiagnostic?["line"] == PureYAML.Model.Value.int(2)) #expect(firstDiagnostic?["column"] == PureYAML.Model.Value.int(9)) #expect(firstDiagnostic?["reason"] == PureYAML.Model.Value.string("missing space after ':' in mapping entry")) @@ -157,6 +163,7 @@ struct DiagnosticValidationReportTests { #expect(report.diagnostics == [ .init( kind: .parse, + code: "trailingWhitespace", severity: .warning, file: "warning.yaml", line: 1, @@ -165,4 +172,58 @@ struct DiagnosticValidationReportTests { ), ]) } + + @Test("Malformed production-shaped YAML reports ten or more structured diagnostics") + func test_malformedProductionShapedYAMLReportsTenOrMoreStructuredDiagnostics() { + let report = PureYAML.diagnosticValidationReport( + [ + "apiVersion:v1", + "\tmetadata:", + "-bad", + "name: demo ", + "image:nginx", + "\t- item", + "-item", + "version:1", + "ports: [80]\t", + "owner:team", + "title: \"open", + ].joined(separator: "\n"), + file: "production-broken.yaml", + ) + + #expect(report.diagnostics.count == 11) + #expect(report.errors.count == 9) + #expect(report.warnings.count == 2) + #expect(report.diagnostics.map(\.code) == [ + "missingMappingSpace", + "tabIndentation", + "missingSequenceSpace", + "trailingWhitespace", + "missingMappingSpace", + "tabIndentation", + "missingSequenceSpace", + "missingMappingSpace", + "trailingWhitespace", + "missingMappingSpace", + "unterminatedQuotedString", + ]) + #expect(report.diagnostics.first == .init( + kind: .parse, + code: "missingMappingSpace", + severity: .error, + file: "production-broken.yaml", + line: 1, + column: 12, + reason: "missing space after ':' in mapping entry", + )) + #expect(report.diagnostics.last == .init( + kind: .parse, + code: "unterminatedQuotedString", + severity: .error, + file: "production-broken.yaml", + line: 11, + reason: "unterminated quoted string at line 11", + )) + } } diff --git a/Tests/DumperTests.swift b/Tests/DumperTests.swift index 4f4d8a4..876932a 100644 --- a/Tests/DumperTests.swift +++ b/Tests/DumperTests.swift @@ -28,11 +28,13 @@ struct DumperTests { let value = PureYAML.Model.Value.mapping(.init([ .init(key: "a:b", value: .string("quote \" slash \\ tab\t")), .init(key: "hash#key", value: .string("line\nnext")), + .init(key: "description ", value: .string("keeps trailing key space")), ])) #expect(PureYAML.dump(value) == """ - "a:b": "quote \\" slash \\\\ tab\\t" + a:b: "quote \\" slash \\\\ tab\\t" "hash#key": "line\\nnext" + "description ": "keeps trailing key space" """) #expect(!PureYAML.dump(value).contains("quote \" slash \\ tab\t")) diff --git a/Tests/EventStreamingParserTests.swift b/Tests/EventStreamingParserTests.swift new file mode 100644 index 0000000..56f61d3 --- /dev/null +++ b/Tests/EventStreamingParserTests.swift @@ -0,0 +1,167 @@ +@testable import PureYAML +import Testing + +@Suite("Event Streaming Parser") +struct EventStreamingParserTests { + @Test( + "Streaming composer matches full event composer for single documents", + arguments: EventStreamingFixtures.singleDocuments, + ) + func test_streamingComposerMatchesFullEventComposer(testCase: EventStreamingFixtures.DocumentCase) throws { + let parser = PureYAML.Parsing.Parser() + let streaming = try parser.parse(testCase.yaml) + let fullEvents = try composeSingleDocumentWithFullEvents(testCase.yaml) + + #expect(streaming == fullEvents) + } + + @Test( + "Streaming composer matches full event composer for streams", + arguments: EventStreamingFixtures.streams, + ) + func test_streamingComposerMatchesFullEventComposerForStreams(testCase: EventStreamingFixtures.DocumentCase) throws { + let parser = PureYAML.Parsing.Parser() + let streaming = try parser.parseStream(testCase.yaml) + let fullEvents = try composeStreamWithFullEvents(testCase.yaml) + + #expect(streaming == fullEvents) + } + + @Test("Streaming parser preserves duplicate keys for validation") + func test_streamingParserPreservesDuplicateKeysForValidation() throws { + let value = try PureYAML.parse(""" + root: + title: First + title: Second + flow: {id: one, id: two} + """) + + let issues = PureYAML.Validation.Validator().collect(value).issues + + #expect(issues.map(\.description) == [ + "error: Duplicate mapping key 'title' at $.root.title", + "error: Duplicate mapping key 'id' at $.flow.id", + ]) + } + + @Test( + "Lazy token cursor matches full scanner tokens", + arguments: EventStreamingFixtures.singleDocuments + EventStreamingFixtures.streams, + ) + func test_lazyTokenCursorMatchesFullScannerTokens(testCase: EventStreamingFixtures.DocumentCase) throws { + let full = try PureYAML.Parsing.Scanner().scan(testCase.yaml) + .filter { !$0.kind.isComment } + let lazy = try collectLazyTokens(testCase.yaml) + + #expect(lazy == full) + } +} + +enum EventStreamingFixtures { + struct DocumentCase: CustomTestStringConvertible { + var name: String + var yaml: String + + var testDescription: String { + name + } + } + + static let singleDocuments = [ + DocumentCase( + name: "block mappings and sequences", + yaml: """ + servers: + - url: / + description: Default + """, + ), + DocumentCase( + name: "anchors aliases and merge keys", + yaml: """ + defaults: &defaults + enabled: true + retries: 3 + service: + <<: *defaults + name: API + """, + ), + DocumentCase( + name: "flow collections and tags", + yaml: """ + flow: [!Thing &item "value", *item, {tagged: ! 'it''s'}] + """, + ), + DocumentCase( + name: "block scalars", + yaml: """ + text: | + one + two + folded: > + three + four + """, + ), + DocumentCase( + name: "JSON style input", + yaml: #"{"openapi":"3.0.0","info":{"title":"JSON API"},"items":["a:b","c"],"enabled":true}"#, + ), + DocumentCase( + name: "complex mapping key", + yaml: """ + ? {name: service, version: 1} + : active + """, + ), + ] + + static let streams = [ + DocumentCase( + name: "multi document sequence stream", + yaml: """ + --- + - one + - two + --- + name: three + ... + --- + null + """, + ), + DocumentCase( + name: "empty explicit documents", + yaml: """ + --- + ... + --- + """, + ), + ] +} + +private func composeSingleDocumentWithFullEvents(_ yaml: String) throws -> PureYAML.Model.Value { + let parser = PureYAML.Parsing.Parser() + let events = try parser.parseEvents(yaml) + var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: parser) + return try composer.compose() +} + +private func composeStreamWithFullEvents(_ yaml: String) throws -> [PureYAML.Stream.Document] { + let parser = PureYAML.Parsing.Parser() + let events = try parser.parseEvents(yaml) + var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: parser) + return try composer.composeStream() +} + +private func collectLazyTokens(_ yaml: String) throws -> [PureYAML.Parsing.Token] { + var cursor = PureYAML.Parsing.TokenCursor(yaml: yaml) + var tokens: [PureYAML.Parsing.Token] = [] + while let token = cursor.current { + tokens.append(token) + _ = try cursor.advance() + } + return tokens +} diff --git a/Tests/JSONCompatibilityTests.swift b/Tests/JSONCompatibilityTests.swift new file mode 100644 index 0000000..7a25ae4 --- /dev/null +++ b/Tests/JSONCompatibilityTests.swift @@ -0,0 +1,171 @@ +import Foundation +@testable import PureYAML +import Testing + +@Suite("JSON Compatibility") +struct JSONCompatibilityTests { + @Test( + "Parses valid JSON as YAML 1.2", + arguments: JSONCompatibilityFixtures.validDocuments, + ) + func test_parsesValidJSONAsYAML(testCase: JSONCompatibilityFixtures.ValidDocument) throws { + let json = try JSONOracle.decode(testCase.json) + let yaml = try JSONOracle(pureYAML: PureYAML.parse(testCase.json)) + + #expect(yaml == json) + } + + @Test( + "JSON oracle rejects invalid JSON fixtures", + arguments: JSONCompatibilityFixtures.invalidDocuments, + ) + func test_jsonOracleRejectsInvalidJSON(testCase: JSONCompatibilityFixtures.InvalidDocument) { + do { + _ = try JSONOracle.decode(testCase.json) + recordIssue("expected JSON oracle to reject \(testCase.name)") + } catch {} + } +} + +enum JSONCompatibilityFixtures { + struct ValidDocument: CustomTestStringConvertible { + var name: String + var json: String + + var testDescription: String { + name + } + } + + struct InvalidDocument: CustomTestStringConvertible { + var name: String + var json: String + + var testDescription: String { + name + } + } + + static let validDocuments = [ + ValidDocument( + name: "compact object", + json: #"{"openapi":"3.0.0","info":{"title":"JSON API","version":"1"},"paths":{},"enabled":true,"count":3}"#, + ), + ValidDocument( + name: "array root", + json: #"[{"a":1},{"b":null},{"items":[true,false]}]"#, + ), + ValidDocument( + name: "scalar roots", + json: #""hello""#, + ), + ValidDocument( + name: "integer root", + json: #"123"#, + ), + ValidDocument( + name: "boolean root", + json: #"true"#, + ), + ValidDocument( + name: "null root", + json: #"null"#, + ), + ValidDocument( + name: "string escapes", + json: #"{"quote":"\"","slash":"\\","solidus":"\/","backspace":"\b","formfeed":"\f","newline":"line\nnext","return":"a\rb","tab":"a\tb"}"#, + ), + ValidDocument( + name: "unicode escapes", + json: #"{"bmp":"\u263A","surrogate":"\uD834\uDD1E"}"#, + ), + ValidDocument( + name: "number forms", + json: #"{"zero":0,"negative":-3,"fraction":1.25,"small":1e-6,"big":6.02E23}"#, + ), + ValidDocument( + name: "insignificant whitespace", + json: """ + { + "items" : [ + { "url" : "https://api.example.com/a:b" }, + { "window" : "10:30" } + ] + } + """, + ), + ValidDocument( + name: "empty containers", + json: #"{"object":{},"array":[]}"#, + ), + ] + + static let invalidDocuments = [ + InvalidDocument(name: "missing value", json: #"{"a":}"#), + InvalidDocument(name: "double comma", json: #"[1,,2]"#), + InvalidDocument(name: "unquoted key", json: #"{a:1}"#), + InvalidDocument(name: "comment", json: #"{"a":1 // no comments in JSON"#), + InvalidDocument(name: "single quoted string", json: #"{'a':1}"#), + ] +} + +enum JSONOracle: Equatable, Decodable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONOracle]) + case object([String: JSONOracle]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONOracle].self) { + self = .array(value) + } else { + self = try .object(container.decode([String: JSONOracle].self)) + } + } + + init(pureYAML value: PureYAML.Model.Value) throws { + switch value { + case .null: + self = .null + case let .bool(value): + self = .bool(value) + case let .int(value): + self = .int(value) + case let .double(value): + self = .double(value) + case let .string(value): + self = .string(value) + case let .sequence(values): + self = try .array(values.map(JSONOracle.init(pureYAML:))) + case let .mapping(mapping): + self = try .object(mapping.pairs.reduce(into: [:]) { result, pair in + guard case let .string(key) = pair.keyNode.value else { + throw JSONOracleFailure.nonStringKey + } + result[key] = try JSONOracle(pureYAML: pair.value) + }) + } + } + + static func decode(_ json: String) throws -> JSONOracle { + try JSONDecoder().decode(JSONOracle.self, from: Data(json.utf8)) + } +} + +enum JSONOracleFailure: Error { + case nonStringKey +} diff --git a/Tests/ParsingCompatibilityTests.swift b/Tests/ParsingCompatibilityTests.swift index 350b842..b2e9f0f 100644 --- a/Tests/ParsingCompatibilityTests.swift +++ b/Tests/ParsingCompatibilityTests.swift @@ -112,6 +112,31 @@ struct ParsingCompatibilityTests { #expect(root?["missing"] == nil) } + @Test("Parses JSON-style compact flow mappings") + func test_jsonStyleCompactFlowMappings() throws { + let root = try requireMapping(PureYAML.parse( + #"{"openapi":"3.0.0","info":{"title":"JSON API","version":"1"},"paths":{},"enabled":true,"count":3}"#, + )) + + #expect(root?["openapi"] == .string("3.0.0")) + #expect(root?.mapping("info")?["title"] == .string("JSON API")) + #expect(root?.mapping("info")?["version"] == .string("1")) + #expect(root?["paths"] == .mapping(.init())) + #expect(root?["enabled"] == .bool(true)) + #expect(root?["count"] == .int(3)) + } + + @Test("Keeps colons inside JSON-style flow mapping values") + func test_jsonStyleFlowMappingValuesCanContainColons() throws { + let root = try requireMapping(PureYAML.parse( + #"{"url":"https://api.example.com/v1","window":"10:30","items":["a:b","c"]}"#, + )) + + #expect(root?["url"] == .string("https://api.example.com/v1")) + #expect(root?["window"] == .string("10:30")) + #expect(root?["items"] == .sequence([.string("a:b"), .string("c")])) + } + @Test( "Resolves selected Yams-compatible scalar spellings", arguments: ScalarCompatibilityFixtures.resolvedScalars, diff --git a/Tests/PerformancePhaseTests.swift b/Tests/PerformancePhaseTests.swift new file mode 100644 index 0000000..03bf685 --- /dev/null +++ b/Tests/PerformancePhaseTests.swift @@ -0,0 +1,298 @@ +import Foundation +@testable import PureYAML +import Testing + +@Suite("Performance Phases") +struct PerformancePhaseTests { + @Test("Profiles parser phases for representative slow corpus seeds") + func test_profilesParserPhasesForRepresentativeSeeds() throws { + guard ProcessInfo.processInfo.environment["PUREYAML_RUN_PHASE_PROFILER"] == "1" else { + return + } + + let measurements = try PerformancePhaseSeed.all.map(profile) + let slowestLazyParse = measurements.map(\.lazyParse.seconds).max() ?? 0 + let slowestMaterializedPipeline = measurements.map(\.materializedPipelineSeconds).max() ?? 0 + let report: [String: Any] = [ + "summary": [ + "seeds": measurements.count, + "slowestLazyParseSeconds": slowestLazyParse, + "slowestMaterializedPipelineSeconds": slowestMaterializedPipeline, + ], + "measurements": measurements.map(\.object), + ] + let artifactURL = try performancePhaseArtifactURL() + let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: artifactURL) + + print("PureYAML phase profiler seeds: \(measurements.count)") + for measurement in measurements { + print(measurement.summaryLine) + } + print("Report: \(artifactURL.path)") + } +} + +private struct PerformancePhaseSeed { + var id: String + var localPath: String + var iterations: Int + + static let all = [ + PerformancePhaseSeed( + id: "cert-manager-crd", + localPath: "cert-manager-certificate-crd.yaml", + iterations: 20, + ), + PerformancePhaseSeed( + id: "stripe-yaml", + localPath: "api-guru-stripe-openapi.yaml", + iterations: 2, + ), + PerformancePhaseSeed( + id: "zoom-yaml", + localPath: "api-guru-zoom-openapi.yaml", + iterations: 2, + ), + ] +} + +private struct PhaseTiming { + var seconds: TimeInterval + var units: Int + + var object: [String: Any] { + [ + "seconds": seconds, + "units": units, + ] + } +} + +private struct PhaseMeasurement { + var seed: PerformancePhaseSeed + var bytes: Int + var scanner: PhaseTiming + var eventParser: PhaseTiming + var composer: PhaseTiming + var lazyParse: PhaseTiming + var validation: PhaseTiming + var dumping: PhaseTiming + + var materializedPipelineSeconds: TimeInterval { + scanner.seconds + eventParser.seconds + composer.seconds + } + + var object: [String: Any] { + [ + "id": seed.id, + "localPath": seed.localPath, + "bytes": bytes, + "iterations": seed.iterations, + "scanner": scanner.object, + "eventParser": eventParser.object, + "composer": composer.object, + "lazyParse": lazyParse.object, + "validation": validation.object, + "dumping": dumping.object, + "materializedPipelineSeconds": materializedPipelineSeconds, + ] + } + + var summaryLine: String { + [ + seed.id, + "bytes=\(bytes)", + "iter=\(seed.iterations)", + "scan=\(format(scanner.seconds))s", + "events=\(format(eventParser.seconds))s", + "compose=\(format(composer.seconds))s", + "lazy=\(format(lazyParse.seconds))s", + "validate=\(format(validation.seconds))s", + "dump=\(format(dumping.seconds))s", + ].joined(separator: " ") + } +} + +private struct WarmedPerformanceInputs { + var tokens: [PureYAML.Parsing.Token] + var events: [PureYAML.Parsing.Event] + var documents: [PureYAML.Stream.Document] +} + +private func profile(_ seed: PerformancePhaseSeed) throws -> PhaseMeasurement { + let yaml = try String(contentsOf: fixtureURL(for: seed), encoding: .utf8) + let parser = PureYAML.Parsing.Parser() + let warmed = try warmup(yaml: yaml, parser: parser) + let scanner = try timeScanner(yaml: yaml, iterations: seed.iterations) + let eventParser = try timeEventParser(tokens: warmed.tokens, parser: parser, iterations: seed.iterations) + let composer = try timeComposer(events: warmed.events, parser: parser, iterations: seed.iterations) + let lazyParse = try timeLazyParse(yaml: yaml, iterations: seed.iterations) + let validation = timeValidation(documents: warmed.documents, iterations: seed.iterations) + let dumping = timeDumping(documents: warmed.documents, iterations: seed.iterations) + + return PhaseMeasurement( + seed: seed, + bytes: yaml.utf8.count, + scanner: .init(seconds: scanner.seconds, units: scanner.value), + eventParser: .init(seconds: eventParser.seconds, units: eventParser.value), + composer: .init(seconds: composer.seconds, units: composer.value), + lazyParse: .init(seconds: lazyParse.seconds, units: lazyParse.value), + validation: .init(seconds: validation.seconds, units: validation.value), + dumping: .init(seconds: dumping.seconds, units: dumping.value), + ) +} + +private func warmup( + yaml: String, + parser: PureYAML.Parsing.Parser, +) throws -> WarmedPerformanceInputs { + let tokens = try PureYAML.Parsing.Scanner().scan(yaml) + var eventParser = PureYAML.Parsing.TokenEventParser(tokens: tokens, scalarParser: parser) + let events = try eventParser.parse() + var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: parser) + let documents = try composer.composeStream() + _ = PureYAML.Validation.Validator().collect(documents) + _ = PureYAML.dump(documents) + _ = try PureYAML.parseStream(yaml) + return WarmedPerformanceInputs(tokens: tokens, events: events, documents: documents) +} + +private func timeScanner( + yaml: String, + iterations: Int, +) throws -> (seconds: TimeInterval, value: Int) { + try timed { + var units = 0 + for _ in 0 ..< iterations { + units += try PureYAML.Parsing.Scanner().scan(yaml).count + } + return units + } +} + +private func timeEventParser( + tokens: [PureYAML.Parsing.Token], + parser: PureYAML.Parsing.Parser, + iterations: Int, +) throws -> (seconds: TimeInterval, value: Int) { + try timed { + var units = 0 + for _ in 0 ..< iterations { + var eventParser = PureYAML.Parsing.TokenEventParser(tokens: tokens, scalarParser: parser) + units += try eventParser.parse().count + } + return units + } +} + +private func timeComposer( + events: [PureYAML.Parsing.Event], + parser: PureYAML.Parsing.Parser, + iterations: Int, +) throws -> (seconds: TimeInterval, value: Int) { + try timed { + var units = 0 + for _ in 0 ..< iterations { + var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: parser) + units += try composer.composeStream().count + } + return units + } +} + +private func timeLazyParse( + yaml: String, + iterations: Int, +) throws -> (seconds: TimeInterval, value: Int) { + try timed { + var units = 0 + for _ in 0 ..< iterations { + units += try PureYAML.parseStream(yaml).count + } + return units + } +} + +private func timeValidation( + documents: [PureYAML.Stream.Document], + iterations: Int, +) -> (seconds: TimeInterval, value: Int) { + timed { + var units = 0 + for _ in 0 ..< iterations { + units += PureYAML.Validation.Validator().collect(documents).issues.count + } + return units + } +} + +private func timeDumping( + documents: [PureYAML.Stream.Document], + iterations: Int, +) -> (seconds: TimeInterval, value: Int) { + timed { + var units = 0 + for _ in 0 ..< iterations { + units += PureYAML.dump(documents).utf8.count + } + return units + } +} + +private func fixtureURL(for seed: PerformancePhaseSeed) throws -> URL { + try #require(Bundle.module.url( + forResource: seed.resourceName, + withExtension: seed.resourceExtension, + subdirectory: "Fixtures/real-yaml", + )) +} + +private func performancePhaseArtifactURL() throws -> URL { + if let path = ProcessInfo.processInfo.environment["PUREYAML_PHASE_PROFILER_ARTIFACT"] { + let url = URL(fileURLWithPath: path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + return url + } + + let url = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".build") + .appendingPathComponent("pureyaml-artifacts") + .appendingPathComponent("performance") + .appendingPathComponent("phase-profile.json") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + return url +} + +private func timed(_ body: () throws -> Int) rethrows -> (seconds: TimeInterval, value: Int) { + let start = DispatchTime.now().uptimeNanoseconds + let value = try body() + let end = DispatchTime.now().uptimeNanoseconds + return (Double(end - start) / 1_000_000_000, value) +} + +private func format(_ value: Double) -> String { + String(format: "%.3f", value) +} + +private extension PerformancePhaseSeed { + var resourceName: String { + guard let dotIndex = localPath.lastIndex(of: ".") else { + return localPath + } + return String(localPath[.. 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 + + var expectedCode: String { + switch self { + case .missingMappingSpace: + "missingMappingSpace" + case .tabIndentation: + "tabIndentation" + case .missingSequenceSpace: + "missingSequenceSpace" + case .unterminatedQuotedString: + "unterminatedQuotedString" + case .undefinedAlias: + "undefinedAlias" + } + } + + func apply(to source: String, seedID: String) -> String { + let sample = source.split(separator: "\n", omittingEmptySubsequences: false) + .prefix(3) + .joined(separator: "\n") + if self == .unterminatedQuotedString { + return """ + # mutation-source: \(seedID) + sample: | + \(sample.replacingOccurrences(of: "\n", with: "\n ")) + mutation: "unterminated scalar + """ + } + + let prefix = switch self { + case .missingMappingSpace: + "mutation:Missing mapping space" + case .tabIndentation: + "\tmutation: tab indentation" + case .missingSequenceSpace: + "-mutation sequence marker" + case .unterminatedQuotedString: + "mutation: \"unterminated scalar" + case .undefinedAlias: + "mutation: *undefinedMutationAlias" + } + 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[.. 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 + } +} diff --git a/Tests/RealYAMLCorpusTests.swift b/Tests/RealYAMLCorpusTests.swift new file mode 100644 index 0000000..261ff35 --- /dev/null +++ b/Tests/RealYAMLCorpusTests.swift @@ -0,0 +1,176 @@ +import Foundation +@testable import PureYAML +import Testing + +@Suite("Real YAML Corpus") +struct RealYAMLCorpusTests { + @Test("Corpus manifest matches vendored fixture files") + func test_corpusManifestMatchesVendoredFixtureFiles() throws { + let manifest = try RealYAMLCorpusManifest.load() + + #expect(manifest.version == 1) + #expect(manifest.seeds.count >= 100) + #expect(manifest.seeds.count(where: { $0.tier == "default" }) == 10) + #expect(manifest.seeds.count(where: { $0.tier == "full" }) >= 90) + #expect(Set(manifest.seeds.map(\.id)).count == manifest.seeds.count) + + for seed in manifest.seeds { + let source = try seed.load() + #expect(source.utf8.count == seed.byteCount) + #expect(source.count(where: { $0 == "\n" }) == seed.lineCount) + #expect(seed.commit.count == 40) + #expect(seed.rawURL.contains(seed.commit)) + #expect(seed.rawURL.contains(seed.sourcePath)) + } + } + + @Test("Full real corpus parses validates and round trips when enabled") + func test_fullRealCorpusParsesValidatesAndRoundTripsWhenEnabled() throws { + guard ProcessInfo.processInfo.environment["PUREYAML_RUN_FULL_CORPUS"] == "1" else { + return + } + + let manifest = try RealYAMLCorpusManifest.load() + for seed in manifest.seeds { + let source = try seed.load() + let documents: [PureYAML.Stream.Document] + do { + documents = try PureYAML.parseStream(source) + } catch { + recordIssue("\(seed.id): parse failed with \(error)") + continue + } + + let issues = PureYAML.Validation.Validator().collect(documents) + let issueDescriptions = issues.issues.map(\.description).joined(separator: "\n") + #expect(issues == PureYAML.Stream.Result(), "\(seed.id): \(issueDescriptions)") + guard seed.expectedRoundTrip == "success" else { + continue + } + + let dumped = PureYAML.dump(documents) + do { + let reparsed = try PureYAML.parseStream(dumped) + #expect(reparsed == documents, "\(seed.id): parse/dump/parse changed the document stream") + } catch { + recordIssue("\(seed.id): dumped YAML failed to parse with \(error)") + } + } + } +} + +private struct RealYAMLCorpusManifest { + var version: Int + var seeds: [RealYAMLCorpusSeed] + + static func load() throws -> Self { + let url = try #require(Bundle.module.url( + forResource: "real-yaml-corpus", + withExtension: "yaml", + subdirectory: "Fixtures", + )) + let value = try PureYAML.parse(String(contentsOf: url, encoding: .utf8)) + let root = try #require(value.mapping) + let version = try #require(root["version"]?.intValue) + let seedValues = try #require(root.sequence("seeds")) + return try RealYAMLCorpusManifest( + version: version, + seeds: seedValues.map(RealYAMLCorpusSeed.init(value:)), + ) + } +} + +private struct RealYAMLCorpusSeed { + var id: String + var localPath: String + var category: String + var size: String + var tier: String + var expectedParse: String + var expectedValidation: String + var expectedRoundTrip: String + var byteCount: Int + var lineCount: Int + var repository: String + var commit: String + var sourcePath: String + var rawURL: String + var license: String + + init(value: PureYAML.Model.Value) throws { + let mapping = try #require(value.mapping) + id = try mapping.requiredString("id") + localPath = try mapping.requiredString("localPath") + category = try mapping.requiredString("category") + size = try mapping.requiredString("size") + tier = try mapping.requiredString("tier") + expectedParse = try mapping.requiredString("expectedParse") + expectedValidation = try mapping.requiredString("expectedValidation") + expectedRoundTrip = try mapping.requiredString("expectedRoundTrip") + byteCount = try mapping.requiredInt("byteCount") + lineCount = try mapping.requiredInt("lineCount") + repository = try mapping.requiredString("repository") + commit = try mapping.requiredString("commit") + sourcePath = try mapping.requiredString("sourcePath") + rawURL = try mapping.requiredString("rawURL") + license = try mapping.requiredString("license") + } + + 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[.. String { + try #require(self[key]?.stringValue) + } + + func requiredInt(_ key: String) throws -> Int { + try #require(self[key]?.intValue) + } +} + +private extension PureYAML.Model.Value { + var stringValue: String? { + guard case let .string(value) = self else { + return nil + } + return value + } + + var intValue: Int? { + guard case let .int(value) = self else { + return nil + } + return value + } +} diff --git a/Tests/ScannerTests.swift b/Tests/ScannerTests.swift index 1a96a8b..01f10c4 100644 --- a/Tests/ScannerTests.swift +++ b/Tests/ScannerTests.swift @@ -223,6 +223,26 @@ struct ScannerTests { expectMarks(value, start: .init(line: 1, column: 7, index: 7), end: .init(line: 1, column: 10, index: 10)) } + @Test("Tracks ASCII and extended UTF-8 marks in one scan") + func test_asciiAndExtendedUTF8Marks() throws { + let tokens = try PureYAML.Parsing.Scanner().scan("ascii: abc\nemoji: 😀\nnext: ok") + guard let emoji = tokens.first(where: { + $0.kind == .scalar(value: "😀", style: .plain) + }) else { + recordIssue("expected emoji scalar") + return + } + guard let next = tokens.first(where: { + $0.kind == .scalar(value: "next", style: .plain) + }) else { + recordIssue("expected next scalar") + return + } + + expectMarks(emoji, start: .init(line: 2, column: 8, index: 18), end: .init(line: 2, column: 9, index: 22)) + expectMarks(next, start: .init(line: 3, column: 1, index: 23), end: .init(line: 3, column: 5, index: 27)) + } + @Test("Reader advances UTF-8 marks across line breaks") func test_readerAdvancesUTF8MarksAcrossLineBreaks() { var reader = PureYAML.Parsing.Reader("a\u{00E9}\n\u{03B2}\r\nz") diff --git a/Tests/ValidationReportTests.swift b/Tests/ValidationReportTests.swift index 08e04a5..8796c78 100644 --- a/Tests/ValidationReportTests.swift +++ b/Tests/ValidationReportTests.swift @@ -20,13 +20,15 @@ struct ValidationReportTests { #expect(report.diagnostics == [ .init( kind: .parse, + code: "unterminatedQuotedString", severity: .error, file: "broken.yaml", + line: 1, reason: "unterminated quoted string at line 1", ), ]) #expect(!report.isValid) - #expect(report.description == "broken.yaml: error: parse: unterminated quoted string at line 1") + #expect(report.description == "broken.yaml: line 1: error: parse: unterminated quoted string at line 1") } @Test("Duplicate keys return document and path diagnostics") @@ -113,7 +115,7 @@ struct ValidationReportTests { #expect(report.diagnosticCount == 3) #expect(!report.isValid) #expect(report.description == [ - "broken.yaml: error: parse: unterminated quoted string at line 1", + "broken.yaml: line 1: error: parse: unterminated quoted string at line 1", "duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'", "warnings.yaml: document[0]: $.mode: warning: validation: Failed to satisfy: Mode is not legacy", ].joined(separator: "\n")) @@ -292,7 +294,7 @@ No diagnostics. Status: invalid ```text -broken.yaml: error: parse: unterminated quoted string at line 1 +broken.yaml: line 1: error: parse: unterminated quoted string at line 1 ``` ## duplicates.yaml @@ -328,14 +330,16 @@ private let expectedReportModelValue = PureYAML.Model.Value.mapping(.init([ .init(key: "diagnostics", value: .sequence([ .mapping(.init([ .init(key: "kind", value: .string("parse")), + .init(key: "code", value: .string("unterminatedQuotedString")), .init(key: "severity", value: .string("error")), .init(key: "file", value: .string("broken.yaml")), + .init(key: "line", value: .int(1)), .init(key: "documentIndex", value: .null), .init(key: "path", value: .null), .init(key: "reason", value: .string("unterminated quoted string at line 1")), .init( key: "description", - value: .string("broken.yaml: error: parse: unterminated quoted string at line 1"), + value: .string("broken.yaml: line 1: error: parse: unterminated quoted string at line 1"), ), ])), ])), diff --git a/docs/CORPUS.md b/docs/CORPUS.md index 09596fd..961d3c5 100644 --- a/docs/CORPUS.md +++ b/docs/CORPUS.md @@ -6,8 +6,121 @@ people actually maintain. ## Current State -The corpus gate is designed but not implemented yet. Implementation is tracked -by: +The checked-in corpus gate is implemented. `Tests/Fixtures/real-yaml-corpus.yaml` +is the machine-readable manifest for 114 pinned public YAML seeds copied under +`Tests/Fixtures/real-yaml/`. + +Normal `swift test` verifies the manifest and vendored file metadata without +running the full corpus parse workload. It also runs deterministic generated +properties over small valid YAML documents and malformed mutations derived from +default real-corpus seeds. Run the full checked-in corpus gate explicitly when +hardening parser behavior: + +```sh +bash scripts/check-corpus.sh +``` + +The opt-in gate sets `PUREYAML_RUN_FULL_CORPUS=1` and runs +`RealYAMLCorpusTests`. It stream-parses every seed, validates every document, +and checks parse/dump/parse stability for seeds marked as round-trippable in the +manifest. A successful run writes corpus artifacts under +`.build/pureyaml-artifacts/`. + +The normal `parse` and `parseStream` paths now scan tokens lazily and compose +values from parser events as those events are produced. They no longer retain a +full token array or a full intermediate event array. `parseEvents` remains +available as a debugging and compatibility surface that materializes events +explicitly. This is an internal memory-reduction step; the public API still +accepts a complete `String`, so true chunked input parsing remains tracked by +#43. + +`RealYAMLCorpusPropertyTests` runs in normal verification and covers: + +- 100 deterministic generated valid YAML documents with parse/dump/parse + stability; +- malformed real-seed mutations for missing mapping spaces, tab indentation, + broken sequence markers, broken quoted scalars, and undefined aliases; +- structured diagnostics for every generated malformed mutation. + +Run the JSON compatibility gate to prove YAML 1.2 JSON-subset behavior against +Swift's JSON decoder: + +```sh +bash scripts/check-json-compatibility.sh +``` + +The gate covers object, array, and scalar roots; JSON escapes; Unicode escape +sequences including surrogate pairs; exponent numbers; nested values; and +insignificant whitespace. It writes +`.build/pureyaml-artifacts/json/json-compatibility.json`. + +Run the Yams comparison gate separately because it intentionally uses an +external dependency in a temporary package under `.build/`: + +```sh +bash scripts/check-yams-differential.sh +``` + +This preserves `Package.swift`'s dependency-free contract while producing +`.build/pureyaml-artifacts/differential/yams-comparison.json`. + +The manifest can mark intentional differences. The current checked-in corpus has +one: a comment-only values file where PureYAML returns a single `null` document +and Yams reports zero stream documents. The differential gate accepts only +manifest-declared intentional differences. + +Malformed-input diagnostics are compared separately: + +```sh +bash scripts/check-yams-diagnostics.sh +``` + +This gate checks production-shaped bad YAML cases where PureYAML emits +machine-readable diagnostic codes, locations, severities, and descriptions, then +records the corresponding Yams parser result in +`.build/pureyaml-artifacts/diagnostics/yams-diagnostics.json`. + +Parsed-document validation is compared separately: + +```sh +bash scripts/check-yams-validation.sh +``` + +This gate checks YAML that both parsers can load but that contains semantic +validation issues such as duplicate mapping keys. PureYAML reports structured +validation paths and reasons; the artifact records Yams' parser result and zero +structured validation issues at +`.build/pureyaml-artifacts/validation/yams-validation.json`. + +Throughput comparison is also opt-in and uses the same temporary-package +pattern: + +```sh +bash scripts/check-throughput.sh +``` + +It measures representative checked-in corpus seeds against Yams in release mode +and writes `.build/pureyaml-artifacts/performance/yams-throughput.json`. + +PureYAML phase profiling is a separate release-mode gate: + +```sh +bash scripts/check-performance-phases.sh +``` + +It times scanner, token-to-event parsing, event composition, lazy full parsing, +validation, and dumping for the slow representative seeds. It writes +`.build/pureyaml-artifacts/performance/phase-profile.json` so parser +optimization work can target the dominant phase instead of guessing from total +Yams comparison time. The scanner now keeps a UTF-8 cursor for byte-accurate +source marks, which avoids per-character string allocation in ASCII-heavy +advancement while preserving Unicode and CRLF behavior. + +Round-trip expectations are tracked separately. A seed can remain in the parse, +validation, and differential gates while skipping parse/dump/parse equality when +the dumper does not yet preserve that document shape. + +Further implementation is tracked by: - #54, the production hardening epic. - #55, the real-world seed, fuzz, and property-based validation gate. @@ -80,14 +193,20 @@ be committed in `Tests/Fixtures/`. Fast/default gate: -- at least 25 pinned public real-world YAML seeds, -- small enough for normal verification, +- 114 pinned public real-world YAML seeds copied into `Tests/Fixtures/real-yaml/`, +- normal verification checks the manifest and file metadata only, - runs on macOS and Linux by default, - documents Windows and WASI depth if they run a smaller subset. Full opt-in gate: -- at least 100 pinned public real-world YAML seeds, +- first opt-in gate covers the 114 checked-in seeds with parse, validation, and + round-trip properties, +- default generated properties cover valid document stability and malformed + real-seed mutation diagnostics, +- Yams differential gate compares parse success and stream document counts for + the checked-in corpus, +- target is met with at least 100 pinned public real-world YAML seeds, - includes at least 20 very large OpenAPI files, - includes at least 20 non-API config files, - includes valid and intentionally invalid upstream fixtures where available. @@ -128,7 +247,7 @@ failure should be reduced into a stable checked-in regression fixture. ## Artifacts -Corpus runs should write artifacts under `.build/pureyaml-artifacts/`. +Corpus runs write artifacts under `.build/pureyaml-artifacts/`. ```text .build/pureyaml-artifacts/ @@ -165,7 +284,7 @@ while it exists, then preserve the important case as a committed fixture. ## Acceptance Checklist -- The fast corpus has at least 25 pinned public real-world YAML seeds. +- The checked-in corpus has at least 25 pinned public real-world YAML seeds. - The full corpus has at least 100 pinned public real-world YAML seeds. - The manifest records provenance and expected outcomes for every seed. - The mutation gate is derived from real seeds. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index d2b40de..52aa326 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,8 +1,8 @@ # Release Process -PureYAML versioning is driven by Git tags. The current release is 0.1.1. +PureYAML versioning is driven by Git tags. The current release is 0.1.2. -## 0.1.1 Release +## 0.1.2 Release - Swift tools version: 6.1 - Product: `PureYAML` @@ -10,7 +10,7 @@ PureYAML versioning is driven by Git tags. The current release is 0.1.1. - Source targets: Swift only, no C targets and no generated parser targets - Hosted CI matrix: macOS build and test, Linux build and test, Windows build and test, WASM build -- Release notes file: `docs/releases/pureyaml-0-1-1.md` +- Release notes file: `docs/releases/pureyaml-0-1-2.md` ## Local Verification @@ -63,19 +63,19 @@ Only run these commands after the local gates pass and hosted CI passes. ```sh git status --short --branch -git tag -a 0.1.1 -m "PureYAML 0.1.1" -git push origin main 0.1.1 -gh release create 0.1.1 \ +git tag -a 0.1.2 -m "PureYAML 0.1.2" +git push origin main 0.1.2 +gh release create 0.1.2 \ --repo mihaelamj/PureYAML \ - --title "PureYAML 0.1.1" \ - --notes-file docs/releases/pureyaml-0-1-1.md + --title "PureYAML 0.1.2" \ + --notes-file docs/releases/pureyaml-0-1-2.md ``` After publishing, verify the release exists: ```sh -git ls-remote --tags origin 0.1.1 -gh release view 0.1.1 --repo mihaelamj/PureYAML +git ls-remote --tags origin 0.1.2 +gh release view 0.1.2 --repo mihaelamj/PureYAML ``` ## Do Not Tag If diff --git a/docs/releases/pureyaml-0-1-2.md b/docs/releases/pureyaml-0-1-2.md new file mode 100644 index 0000000..80569f4 --- /dev/null +++ b/docs/releases/pureyaml-0-1-2.md @@ -0,0 +1,49 @@ +# PureYAML 0.1.2 + +PureYAML 0.1.2 is a production-hardening release focused on real-world corpus +coverage, structured diagnostics, Yams comparison gates, JSON compatibility, and +lower-memory parser internals. + +## Highlights + +- Add a 114-seed real-world YAML corpus with opt-in full parse, validation, and + round-trip checks. +- Add structured parser diagnostic codes and validation reports for malformed + production-shaped YAML. +- Add JSON-subset compatibility tests against Swift's JSON decoder. +- Add Yams differential, diagnostic, validation, throughput, and phase-profiling + artifact gates without adding Yams to the public package manifest. +- Route normal parsing through lazy token scanning and streamed event + composition so `parse` and `parseStream` no longer retain a full token array or + full intermediate event array. +- Improve ASCII-heavy scanner advancement with a UTF-8 reader cursor while + preserving byte-accurate Unicode and CRLF source marks. + +## Current Production Readiness + +The checked corpus agrees with Yams on parse success and stream document counts. +PureYAML now has structured validation that reports duplicate keys with paths, +reasons, and severities, while Yams does not provide equivalent structured +validation output. + +The public API still accepts complete `String` input. True chunked file/network +input streaming remains deferred to #43. + +## Verification + +The release candidate was locally verified with: + +- `bash scripts/check-all.sh` +- `bash scripts/check-corpus.sh` +- `bash scripts/check-json-compatibility.sh` +- `bash scripts/check-yams-differential.sh` +- `bash scripts/check-yams-diagnostics.sh` +- `bash scripts/check-yams-validation.sh` +- `bash scripts/check-throughput.sh` +- `bash scripts/check-performance-phases.sh` + +## Installation + +```swift +.package(url: "https://github.com/mihaelamj/PureYAML.git", .upToNextMinor(from: "0.1.2")) +``` diff --git a/scripts/check-corpus.sh b/scripts/check-corpus.sh new file mode 100755 index 0000000..4a65c88 --- /dev/null +++ b/scripts/check-corpus.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ARTIFACT_DIR="$ROOT_DIR/.build/pureyaml-artifacts" + +PUREYAML_RUN_FULL_CORPUS=1 swift test --filter RealYAMLCorpusTests + +mkdir -p "$ARTIFACT_DIR" + +python3 - "$ROOT_DIR" "$ARTIFACT_DIR" <<'PY' +import json +import platform +import subprocess +import sys +from collections import Counter +from pathlib import Path + +root = Path(sys.argv[1]) +artifact_dir = Path(sys.argv[2]) +manifest_path = root / "Tests" / "Fixtures" / "real-yaml-corpus.yaml" + +def parse_manifest(path): + version = None + description = None + seeds = [] + current = None + for raw_line in path.read_text().splitlines(): + if raw_line.startswith("version: "): + version = int(raw_line.split(": ", 1)[1]) + elif raw_line.startswith("description: "): + description = raw_line.split(": ", 1)[1] + elif raw_line.startswith(" - id: "): + if current is not None: + seeds.append(current) + current = {"id": raw_line.split(": ", 1)[1]} + elif current is not None and raw_line.startswith(" "): + key, value = raw_line.strip().split(": ", 1) + if key in {"byteCount", "lineCount"}: + current[key] = int(value) + else: + current[key] = value + if current is not None: + seeds.append(current) + return {"version": version, "description": description, "seeds": seeds} + +def command_output(command): + try: + return subprocess.check_output( + command, + cwd=root, + stderr=subprocess.STDOUT, + text=True, + ).strip() + except Exception as error: + return f"unavailable: {error}" + +manifest = parse_manifest(manifest_path) +seeds = manifest["seeds"] +categories = Counter(seed["category"] for seed in seeds) +tiers = Counter(seed["tier"] for seed in seeds) +sizes = Counter(seed["size"] for seed in seeds) + +summary = { + "seedCount": len(seeds), + "defaultSeeds": tiers["default"], + "fullSeeds": tiers["full"], + "totalBytes": sum(seed["byteCount"] for seed in seeds), + "totalLines": sum(seed["lineCount"] for seed in seeds), + "categories": dict(sorted(categories.items())), + "sizes": dict(sorted(sizes.items())), +} +generated_summary = { + "validDocumentSeed": "0x505559414D4C", + "validDocumentCases": 100, + "mutationSourceTier": "default", + "mutationSourceSeeds": tiers["default"], + "mutationClasses": [ + "missingMappingSpace", + "tabIndentation", + "missingSequenceSpace", + "unterminatedQuotedString", + "undefinedAlias", + ], + "totalMutations": tiers["default"] * 5, + "status": "passed", +} + +(artifact_dir / "real-seed-manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n" +) +(artifact_dir / "seeds.json").write_text( + json.dumps( + { + "generated": generated_summary, + "realSeeds": [{"id": seed["id"], "tier": seed["tier"]} for seed in seeds], + }, + indent=2, + sort_keys=True, + ) + "\n" +) +(artifact_dir / "generated-validation-summary.json").write_text( + json.dumps(generated_summary, indent=2, sort_keys=True) + "\n" +) + +def yaml_mapping(mapping, indentation=0): + lines = [] + spaces = " " * indentation + for key, value in mapping.items(): + if isinstance(value, dict): + lines.append(f"{spaces}{key}:") + lines.extend(yaml_mapping(value, indentation + 2)) + elif isinstance(value, list): + lines.append(f"{spaces}{key}:") + for item in value: + lines.append(f"{spaces} - {item}") + else: + lines.append(f"{spaces}{key}: {value}") + return lines + +(artifact_dir / "real-seed-summary.yaml").write_text( + "\n".join(yaml_mapping(summary)) + "\n" +) +(artifact_dir / "generated-validation-summary.yaml").write_text( + "\n".join(yaml_mapping(generated_summary)) + "\n" +) +(artifact_dir / "environment.txt").write_text( + "\n".join( + [ + f"python: {platform.python_version()}", + f"platform: {platform.platform()}", + f"swift: {command_output(['swift', '--version'])}", + f"git: {command_output(['git', 'rev-parse', 'HEAD'])}", + f"git_status: {command_output(['git', 'status', '--short'])}", + ] + ) + "\n" +) + +print(f"corpus artifacts: {artifact_dir}") +PY diff --git a/scripts/check-json-compatibility.sh b/scripts/check-json-compatibility.sh new file mode 100755 index 0000000..777b16c --- /dev/null +++ b/scripts/check-json-compatibility.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$ROOT_DIR/.build/json-compatibility" +ARTIFACT_DIR="$ROOT_DIR/.build/pureyaml-artifacts/json" + +mkdir -p "$WORK_DIR/Sources/JSONCompatibility" "$ARTIFACT_DIR" + +cat > "$WORK_DIR/Package.swift" < "$WORK_DIR/Sources/JSONCompatibility/main.swift" <<'EOF' +import Foundation +import PureYAML + +struct ValidCase { + var name: String + var json: String +} + +struct InvalidCase { + var name: String + var json: String +} + +let arguments = CommandLine.arguments +guard arguments.count == 2 else { + FileHandle.standardError.write(Data("usage: JSONCompatibility \n".utf8)) + Foundation.exit(64) +} + +let validCases = [ + ValidCase( + name: "compact object", + json: #"{"openapi":"3.0.0","info":{"title":"JSON API","version":"1"},"paths":{},"enabled":true,"count":3}"#, + ), + ValidCase(name: "array root", json: #"[{"a":1},{"b":null},{"items":[true,false]}]"#), + ValidCase(name: "string root", json: #""hello""#), + ValidCase(name: "integer root", json: #"123"#), + ValidCase(name: "boolean root", json: #"true"#), + ValidCase(name: "null root", json: #"null"#), + ValidCase( + name: "string escapes", + json: #"{"quote":"\"","slash":"\\","solidus":"\/","backspace":"\b","formfeed":"\f","newline":"line\nnext","return":"a\rb","tab":"a\tb"}"#, + ), + ValidCase(name: "unicode escapes", json: #"{"bmp":"\u263A","surrogate":"\uD834\uDD1E"}"#), + ValidCase(name: "number forms", json: #"{"zero":0,"negative":-3,"fraction":1.25,"small":1e-6,"big":6.02E23}"#), + ValidCase( + name: "insignificant whitespace", + json: """ + { + "items" : [ + { "url" : "https://api.example.com/a:b" }, + { "window" : "10:30" } + ] + } + """, + ), + ValidCase(name: "empty containers", json: #"{"object":{},"array":[]}"#), +] + +let invalidCases = [ + InvalidCase(name: "missing value", json: #"{"a":}"#), + InvalidCase(name: "double comma", json: #"[1,,2]"#), + InvalidCase(name: "unquoted key", json: #"{a:1}"#), + InvalidCase(name: "comment", json: #"{"a":1 // no comments in JSON"#), + InvalidCase(name: "single quoted string", json: #"{'a':1}"#), +] + +let validResults = validCases.map { testCase -> [String: Any] in + do { + let json = try JSONOracle.decode(testCase.json) + let yaml = try JSONOracle(pureYAML: PureYAML.parse(testCase.json)) + return [ + "name": testCase.name, + "acceptedByJSONOracle": true, + "acceptedByPureYAML": true, + "structurallyEqual": yaml == json, + ] + } catch { + return [ + "name": testCase.name, + "acceptedByJSONOracle": false, + "acceptedByPureYAML": false, + "structurallyEqual": false, + "error": String(describing: error), + ] + } +} + +let invalidResults = invalidCases.map { testCase -> [String: Any] in + do { + _ = try JSONOracle.decode(testCase.json) + return [ + "name": testCase.name, + "rejectedByJSONOracle": false, + ] + } catch { + return [ + "name": testCase.name, + "rejectedByJSONOracle": true, + ] + } +} + +let validFailures = validResults.filter { result in + result["structurallyEqual"] as? Bool != true +} +let invalidFailures = invalidResults.filter { result in + result["rejectedByJSONOracle"] as? Bool != true +} +let report: [String: Any] = [ + "summary": [ + "validCases": validCases.count, + "validCasesStructurallyEqual": validCases.count - validFailures.count, + "invalidCases": invalidCases.count, + "invalidCasesRejectedByOracle": invalidCases.count - invalidFailures.count, + "failures": validFailures.count + invalidFailures.count, + ], + "valid": validResults, + "invalid": invalidResults, +] + +let artifactURL = URL(fileURLWithPath: arguments[1]) +let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: artifactURL) + +print("JSON compatibility valid cases: \(validCases.count)") +print("JSON compatibility structurally equal: \(validCases.count - validFailures.count)") +print("JSON invalid oracle rejections: \(invalidCases.count - invalidFailures.count)") +print("Report: \(artifactURL.path)") + +if !validFailures.isEmpty || !invalidFailures.isEmpty { + Foundation.exit(1) +} + +enum JSONOracle: Equatable, Decodable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONOracle]) + case object([String: JSONOracle]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONOracle].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONOracle].self)) + } + } + + init(pureYAML value: PureYAML.Model.Value) throws { + switch value { + case .null: + self = .null + case let .bool(value): + self = .bool(value) + case let .int(value): + self = .int(value) + case let .double(value): + self = .double(value) + case let .string(value): + self = .string(value) + case let .sequence(values): + self = .array(try values.map(JSONOracle.init(pureYAML:))) + case let .mapping(mapping): + self = .object(try mapping.pairs.reduce(into: [:]) { result, pair in + guard case let .string(key) = pair.keyNode.value else { + throw JSONOracleFailure.nonStringKey + } + result[key] = try JSONOracle(pureYAML: pair.value) + }) + } + } + + static func decode(_ json: String) throws -> JSONOracle { + try JSONDecoder().decode(JSONOracle.self, from: Data(json.utf8)) + } +} + +enum JSONOracleFailure: Error { + case nonStringKey +} +EOF + +swift run \ + --package-path "$WORK_DIR" \ + JSONCompatibility \ + "$ARTIFACT_DIR/json-compatibility.json" diff --git a/scripts/check-performance-phases.sh b/scripts/check-performance-phases.sh new file mode 100755 index 0000000..70a6ecd --- /dev/null +++ b/scripts/check-performance-phases.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ARTIFACT_DIR="$ROOT_DIR/.build/pureyaml-artifacts/performance" + +mkdir -p "$ARTIFACT_DIR" + +PUREYAML_RUN_PHASE_PROFILER=1 \ +PUREYAML_PHASE_PROFILER_ARTIFACT="$ARTIFACT_DIR/phase-profile.json" \ +swift test \ + -c release \ + --package-path "$ROOT_DIR" \ + --filter PerformancePhaseTests diff --git a/scripts/check-throughput.sh b/scripts/check-throughput.sh new file mode 100755 index 0000000..40ac353 --- /dev/null +++ b/scripts/check-throughput.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$ROOT_DIR/.build/yams-throughput" +ARTIFACT_DIR="$ROOT_DIR/.build/pureyaml-artifacts/performance" + +mkdir -p "$WORK_DIR/Sources/YamsThroughput" "$ARTIFACT_DIR" + +cat > "$WORK_DIR/Package.swift" < "$WORK_DIR/Sources/YamsThroughput/main.swift" <<'EOF' +import Foundation +import PureYAML +import Yams + +struct Seed { + var id: String + var localPath: String + var iterations: Int +} + +struct Measurement { + var seed: Seed + var bytes: Int + var pureSeconds: TimeInterval + var yamsSeconds: TimeInterval + var pureDocuments: Int + var yamsDocuments: Int + + var pureMegabytesPerSecond: Double { + Double(bytes * seed.iterations) / pureSeconds / 1_000_000 + } + + var yamsMegabytesPerSecond: Double { + Double(bytes * seed.iterations) / yamsSeconds / 1_000_000 + } + + var ratio: Double { + pureMegabytesPerSecond / yamsMegabytesPerSecond + } + + var object: [String: Any] { + [ + "id": seed.id, + "localPath": seed.localPath, + "bytes": bytes, + "iterations": seed.iterations, + "pureyaml": [ + "seconds": pureSeconds, + "megabytesPerSecond": pureMegabytesPerSecond, + "documents": pureDocuments, + ], + "yams": [ + "seconds": yamsSeconds, + "megabytesPerSecond": yamsMegabytesPerSecond, + "documents": yamsDocuments, + ], + "pureyamlToYamsRatio": ratio, + ] + } +} + +let arguments = CommandLine.arguments +guard arguments.count == 3 else { + FileHandle.standardError.write(Data("usage: YamsThroughput \n".utf8)) + Foundation.exit(64) +} + +let rootURL = URL(fileURLWithPath: arguments[1], isDirectory: true) +let artifactURL = URL(fileURLWithPath: arguments[2]) +let fixtureURL = rootURL + .appendingPathComponent("Tests") + .appendingPathComponent("Fixtures") + .appendingPathComponent("real-yaml") + +let seeds = [ + Seed(id: "petstore", localPath: "openapi-petstore.yaml", iterations: 100), + Seed(id: "prometheus", localPath: "prometheus-conf-good.yml", iterations: 60), + Seed(id: "cert-manager-crd", localPath: "cert-manager-certificate-crd.yaml", iterations: 20), + Seed(id: "stripe-yaml", localPath: "api-guru-stripe-openapi.yaml", iterations: 2), + Seed(id: "zoom-yaml", localPath: "api-guru-zoom-openapi.yaml", iterations: 2), +] + +let measurements = try seeds.map { seed in + let url = fixtureURL.appendingPathComponent(seed.localPath) + let yaml = try String(contentsOf: url, encoding: .utf8) + + _ = try PureYAML.parseStream(yaml) + _ = try Array(Yams.load_all(yaml: yaml)) + + let pure = try timed { + var documents = 0 + for _ in 0..= 1 }.count, + "pureyamlSlower": measurements.filter { $0.ratio < 1 }.count, + "minimumRatio": measurements.map(\.ratio).min() ?? 0, + ], + "measurements": measurements.map(\.object), +] +let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: artifactURL) + +print("Yams throughput corpus seeds: \(measurements.count)") +for measurement in measurements { + print([ + measurement.seed.id, + "bytes=\(measurement.bytes)", + "iter=\(measurement.seed.iterations)", + "pure=\(format(measurement.pureMegabytesPerSecond))MB/s", + "yams=\(format(measurement.yamsMegabytesPerSecond))MB/s", + "ratio=\(format(measurement.ratio))", + ].joined(separator: " ")) +} +print("Report: \(artifactURL.path)") + +func timed(_ body: () throws -> Int) rethrows -> (seconds: TimeInterval, value: Int) { + let start = DispatchTime.now().uptimeNanoseconds + let value = try body() + let end = DispatchTime.now().uptimeNanoseconds + return (Double(end - start) / 1_000_000_000, value) +} + +func format(_ value: Double) -> String { + String(format: "%.2f", value) +} +EOF + +swift run \ + -c release \ + --package-path "$WORK_DIR" \ + YamsThroughput \ + "$ROOT_DIR" \ + "$ARTIFACT_DIR/yams-throughput.json" diff --git a/scripts/check-yams-diagnostics.sh b/scripts/check-yams-diagnostics.sh new file mode 100755 index 0000000..ed11af3 --- /dev/null +++ b/scripts/check-yams-diagnostics.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$ROOT_DIR/.build/yams-diagnostics" +ARTIFACT_DIR="$ROOT_DIR/.build/pureyaml-artifacts/diagnostics" + +mkdir -p "$WORK_DIR/Sources/YamsDiagnostics" "$ARTIFACT_DIR" + +cat > "$WORK_DIR/Package.swift" < "$WORK_DIR/Sources/YamsDiagnostics/main.swift" <<'EOF' +import Foundation +import PureYAML +import Yams + +struct Case { + var id: String + var yaml: String + var expectedCodes: [String] +} + +struct Comparison { + var testCase: Case + var pureDiagnostics: [PureYAML.Validation.Diagnostic] + var yamsSuccess: Bool + var yamsDocumentCount: Int? + var yamsError: String? + + var hasExpectedCodes: Bool { + let codes = Set(pureDiagnostics.compactMap(\.code)) + return testCase.expectedCodes.allSatisfy { codes.contains($0) } + } + + var object: [String: Any] { + [ + "id": testCase.id, + "expectedCodes": testCase.expectedCodes, + "pureyaml": [ + "diagnostics": pureDiagnostics.map(diagnosticObject), + "diagnosticCount": pureDiagnostics.count, + "errorCount": pureDiagnostics.filter { $0.severity == .error }.count, + "warningCount": pureDiagnostics.filter { $0.severity == .warning }.count, + "hasExpectedCodes": hasExpectedCodes, + ], + "yams": [ + "success": yamsSuccess, + "documentCount": yamsDocumentCount as Any, + "error": yamsError as Any, + "structuredDiagnostics": false, + ], + ] + } +} + +let arguments = CommandLine.arguments +guard arguments.count == 2 else { + FileHandle.standardError.write(Data("usage: YamsDiagnostics \n".utf8)) + Foundation.exit(64) +} + +let artifactURL = URL(fileURLWithPath: arguments[1]) +let cases = [ + Case( + id: "missing-mapping-space", + yaml: "apiVersion:v1\nkind: Pod\n", + expectedCodes: ["missingMappingSpace"], + ), + Case( + id: "tab-indentation", + yaml: "root:\n\tchild: value\n", + expectedCodes: ["tabIndentation"], + ), + Case( + id: "missing-sequence-space", + yaml: "-bad\n- ok\n", + expectedCodes: ["missingSequenceSpace"], + ), + Case( + id: "unterminated-quoted-string", + yaml: "title: \"open\nsummary: still scanned\n", + expectedCodes: ["unterminatedQuotedString"], + ), + Case( + id: "production-shaped-batch", + yaml: [ + "apiVersion:v1", + "\tmetadata:", + "-bad", + "name: demo ", + "image:nginx", + "\t- item", + "-item", + "version:1", + "ports: [80]\t", + "owner:team", + "title: \"open", + ].joined(separator: "\n"), + expectedCodes: [ + "missingMappingSpace", + "tabIndentation", + "missingSequenceSpace", + "trailingWhitespace", + "unterminatedQuotedString", + ], + ), +] + +let comparisons = cases.map { testCase in + let report = PureYAML.diagnosticValidationReport(testCase.yaml, file: "\(testCase.id).yaml") + let yams = parseWithYams(testCase.yaml) + return Comparison( + testCase: testCase, + pureDiagnostics: report.diagnostics, + yamsSuccess: yams.success, + yamsDocumentCount: yams.documentCount, + yamsError: yams.error, + ) +} + +let failures = comparisons.filter { $0.pureDiagnostics.isEmpty || !$0.hasExpectedCodes } +let report: [String: Any] = [ + "summary": [ + "cases": comparisons.count, + "pureyamlStructuredCases": comparisons.filter { !$0.pureDiagnostics.isEmpty }.count, + "pureyamlCasesWithExpectedCodes": comparisons.filter(\.hasExpectedCodes).count, + "yamsStructuredCases": 0, + "failures": failures.count, + ], + "comparisons": comparisons.map(\.object), +] + +let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: artifactURL) + +print("Yams diagnostic comparison cases: \(comparisons.count)") +print("PureYAML structured cases: \(comparisons.filter { !$0.pureDiagnostics.isEmpty }.count)") +print("PureYAML cases with expected codes: \(comparisons.filter(\.hasExpectedCodes).count)") +print("Yams structured cases: 0") +print("Report: \(artifactURL.path)") + +if !failures.isEmpty { + for comparison in failures { + print("DIAGNOSTIC GAP \(comparison.testCase.id)") + print(" expected codes: \(comparison.testCase.expectedCodes.joined(separator: ", "))") + print(" actual codes: \(comparison.pureDiagnostics.compactMap(\.code).joined(separator: ", "))") + } + Foundation.exit(1) +} + +func parseWithYams(_ yaml: String) -> (success: Bool, documentCount: Int?, error: String?) { + do { + let documents = try Yams.load_all(yaml: yaml) + return (true, Array(documents).count, nil) + } catch { + return (false, nil, String(describing: error)) + } +} + +func diagnosticObject(_ diagnostic: PureYAML.Validation.Diagnostic) -> [String: Any] { + [ + "kind": diagnostic.kind.description, + "code": diagnostic.code as Any, + "severity": diagnostic.severity.description, + "file": diagnostic.file as Any, + "line": diagnostic.line as Any, + "column": diagnostic.column as Any, + "documentIndex": diagnostic.documentIndex as Any, + "path": diagnostic.path?.description as Any, + "reason": diagnostic.reason, + "description": diagnostic.description, + ] +} +EOF + +swift run \ + --package-path "$WORK_DIR" \ + YamsDiagnostics \ + "$ARTIFACT_DIR/yams-diagnostics.json" diff --git a/scripts/check-yams-differential.sh b/scripts/check-yams-differential.sh new file mode 100755 index 0000000..e5cbfbb --- /dev/null +++ b/scripts/check-yams-differential.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$ROOT_DIR/.build/yams-differential" +ARTIFACT_DIR="$ROOT_DIR/.build/pureyaml-artifacts/differential" + +mkdir -p "$WORK_DIR/Sources/YamsDifferential" "$ARTIFACT_DIR" + +cat > "$WORK_DIR/Package.swift" < "$WORK_DIR/Sources/YamsDifferential/main.swift" <<'EOF' +import Foundation +import PureYAML +import Yams + +struct Seed { + var id: String + var localPath: String + var category: String + var tier: String + var repository: String + var commit: String + var sourcePath: String + var expectedDifferential: String +} + +struct Comparison { + var seed: Seed + var pureSuccess: Bool + var pureDocumentCount: Int? + var pureError: String? + var yamsSuccess: Bool + var yamsDocumentCount: Int? + var yamsError: String? + + var agrees: Bool { + if seed.expectedDifferential == "yamsEmptyStreamZeroDocuments" { + return pureSuccess && yamsSuccess && pureDocumentCount == 1 && yamsDocumentCount == 0 + } + return pureSuccess == yamsSuccess && pureDocumentCount == yamsDocumentCount + } + + var object: [String: Any] { + [ + "id": seed.id, + "localPath": seed.localPath, + "category": seed.category, + "tier": seed.tier, + "repository": seed.repository, + "commit": seed.commit, + "sourcePath": seed.sourcePath, + "expectedDifferential": seed.expectedDifferential, + "agrees": agrees, + "pureyaml": [ + "success": pureSuccess, + "documentCount": pureDocumentCount as Any, + "error": pureError as Any, + ], + "yams": [ + "success": yamsSuccess, + "documentCount": yamsDocumentCount as Any, + "error": yamsError as Any, + ], + ] + } +} + +let arguments = CommandLine.arguments +guard arguments.count == 3 else { + FileHandle.standardError.write(Data("usage: YamsDifferential \n".utf8)) + Foundation.exit(64) +} + +let rootURL = URL(fileURLWithPath: arguments[1], isDirectory: true) +let artifactURL = URL(fileURLWithPath: arguments[2]) +let manifestURL = rootURL + .appendingPathComponent("Tests") + .appendingPathComponent("Fixtures") + .appendingPathComponent("real-yaml-corpus.yaml") + +let manifest = try PureYAML.parse(String(contentsOf: manifestURL, encoding: .utf8)) +let seeds = try loadSeeds(from: manifest) + +var comparisons: [Comparison] = [] +for seed in seeds { + let sourceURL = rootURL + .appendingPathComponent("Tests") + .appendingPathComponent("Fixtures") + .appendingPathComponent(seed.localPath) + let yaml = try String(contentsOf: sourceURL, encoding: .utf8) + + let pure = parseWithPureYAML(yaml) + let yams = parseWithYams(yaml) + comparisons.append(Comparison( + seed: seed, + pureSuccess: pure.success, + pureDocumentCount: pure.documentCount, + pureError: pure.error, + yamsSuccess: yams.success, + yamsDocumentCount: yams.documentCount, + yamsError: yams.error, + )) +} + +let disagreements = comparisons.filter { !$0.agrees } +let report: [String: Any] = [ + "summary": [ + "seeds": comparisons.count, + "agreements": comparisons.count - disagreements.count, + "disagreements": disagreements.count, + ], + "comparisons": comparisons.map(\.object), +] + +let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: artifactURL) + +print("Yams differential corpus seeds: \(comparisons.count)") +print("Agreements: \(comparisons.count - disagreements.count)") +print("Disagreements: \(disagreements.count)") +print("Report: \(artifactURL.path)") + +if !disagreements.isEmpty { + for comparison in disagreements { + print("DISAGREE \(comparison.seed.id)") + print(" PureYAML: \(comparison.pureSuccess) documents=\(comparison.pureDocumentCount.map(String.init) ?? "nil") error=\(comparison.pureError ?? "nil")") + print(" Yams: \(comparison.yamsSuccess) documents=\(comparison.yamsDocumentCount.map(String.init) ?? "nil") error=\(comparison.yamsError ?? "nil")") + } + Foundation.exit(1) +} + +func parseWithPureYAML(_ yaml: String) -> (success: Bool, documentCount: Int?, error: String?) { + do { + let documents = try PureYAML.parseStream(yaml) + return (true, documents.count, nil) + } catch { + return (false, nil, String(describing: error)) + } +} + +func parseWithYams(_ yaml: String) -> (success: Bool, documentCount: Int?, error: String?) { + do { + let documents = try Yams.load_all(yaml: yaml) + return (true, Array(documents).count, nil) + } catch { + return (false, nil, String(describing: error)) + } +} + +func loadSeeds(from value: PureYAML.Model.Value) throws -> [Seed] { + guard case let .mapping(root) = value, + case let .sequence(seedValues)? = root["seeds"] + else { + throw Failure("real-yaml-corpus.yaml must contain a seeds sequence") + } + + return try seedValues.map { value in + guard case let .mapping(mapping) = value else { + throw Failure("seed entry must be a mapping") + } + return Seed( + id: try mapping.requiredString("id"), + localPath: try mapping.requiredString("localPath"), + category: try mapping.requiredString("category"), + tier: try mapping.requiredString("tier"), + repository: try mapping.requiredString("repository"), + commit: try mapping.requiredString("commit"), + sourcePath: try mapping.requiredString("sourcePath"), + expectedDifferential: try mapping.requiredString("expectedDifferential"), + ) + } +} + +struct Failure: Error, CustomStringConvertible { + var description: String + + init(_ description: String) { + self.description = description + } +} + +extension PureYAML.Model.Mapping { + func requiredString(_ key: String) throws -> String { + guard case let .string(value)? = self[key] else { + throw Failure("seed entry missing string key \(key)") + } + return value + } +} +EOF + +swift run \ + --package-path "$WORK_DIR" \ + YamsDifferential \ + "$ROOT_DIR" \ + "$ARTIFACT_DIR/yams-comparison.json" diff --git a/scripts/check-yams-validation.sh b/scripts/check-yams-validation.sh new file mode 100755 index 0000000..e8672cb --- /dev/null +++ b/scripts/check-yams-validation.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$ROOT_DIR/.build/yams-validation" +ARTIFACT_DIR="$ROOT_DIR/.build/pureyaml-artifacts/validation" + +mkdir -p "$WORK_DIR/Sources/YamsValidation" "$ARTIFACT_DIR" + +cat > "$WORK_DIR/Package.swift" < "$WORK_DIR/Sources/YamsValidation/main.swift" <<'EOF' +import Foundation +import PureYAML +import Yams + +struct Case { + var id: String + var yaml: String + var expectedIssueCount: Int +} + +struct Comparison { + var testCase: Case + var pureParseSuccess: Bool + var pureIssues: [PureYAML.Validation.Issue] + var pureError: String? + var yamsParseSuccess: Bool + var yamsDocumentCount: Int? + var yamsError: String? + + var provesValidationAdvantage: Bool { + pureParseSuccess + && pureIssues.count == testCase.expectedIssueCount + && yamsParseSuccess + } + + var object: [String: Any] { + [ + "id": testCase.id, + "expectedIssueCount": testCase.expectedIssueCount, + "provesValidationAdvantage": provesValidationAdvantage, + "pureyaml": [ + "parseSuccess": pureParseSuccess, + "validationIssueCount": pureIssues.count, + "validationIssues": pureIssues.map(issueObject), + "error": pureError as Any, + ], + "yams": [ + "parseSuccess": yamsParseSuccess, + "documentCount": yamsDocumentCount as Any, + "error": yamsError as Any, + "structuredValidationIssues": 0, + ], + ] + } +} + +let arguments = CommandLine.arguments +guard arguments.count == 2 else { + FileHandle.standardError.write(Data("usage: YamsValidation \n".utf8)) + Foundation.exit(64) +} + +let cases = [ + Case( + id: "root-duplicate-key", + yaml: """ + title: First + title: Second + """, + expectedIssueCount: 1, + ), + Case( + id: "nested-block-and-flow-duplicates", + yaml: """ + root: + title: First + title: Second + children: + - name: One + name: Two + flow: {id: one, id: two} + """, + expectedIssueCount: 3, + ), + Case( + id: "openapi-path-duplicate", + yaml: """ + openapi: "3.1.0" + paths: + /users: + get: + operationId: listUsers + /users: + post: + operationId: createUser + """, + expectedIssueCount: 1, + ), +] + +let comparisons = cases.map { testCase in + let pure = validateWithPureYAML(testCase.yaml) + let yams = parseWithYams(testCase.yaml) + return Comparison( + testCase: testCase, + pureParseSuccess: pure.parseSuccess, + pureIssues: pure.issues, + pureError: pure.error, + yamsParseSuccess: yams.success, + yamsDocumentCount: yams.documentCount, + yamsError: yams.error, + ) +} + +let failures = comparisons.filter { !$0.provesValidationAdvantage } +let report: [String: Any] = [ + "summary": [ + "cases": comparisons.count, + "pureyamlStructuredValidationCases": comparisons.filter { !$0.pureIssues.isEmpty }.count, + "yamsStructuredValidationCases": 0, + "failures": failures.count, + ], + "comparisons": comparisons.map(\.object), +] + +let artifactURL = URL(fileURLWithPath: arguments[1]) +let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: artifactURL) + +print("Yams validation comparison cases: \(comparisons.count)") +print("PureYAML structured validation cases: \(comparisons.filter { !$0.pureIssues.isEmpty }.count)") +print("Yams structured validation cases: 0") +print("Report: \(artifactURL.path)") + +if !failures.isEmpty { + for comparison in failures { + print("VALIDATION GAP \(comparison.testCase.id)") + print(" PureYAML parse=\(comparison.pureParseSuccess) issues=\(comparison.pureIssues.count) error=\(comparison.pureError ?? "nil")") + print(" Yams parse=\(comparison.yamsParseSuccess) docs=\(comparison.yamsDocumentCount.map(String.init) ?? "nil") error=\(comparison.yamsError ?? "nil")") + } + Foundation.exit(1) +} + +func validateWithPureYAML(_ yaml: String) -> (parseSuccess: Bool, issues: [PureYAML.Validation.Issue], error: String?) { + do { + let documents = try PureYAML.parseStream(yaml) + let result = PureYAML.Validation.Validator().collect(documents) + return (true, result.issues.map(\.issue), nil) + } catch { + return (false, [], String(describing: error)) + } +} + +func parseWithYams(_ yaml: String) -> (success: Bool, documentCount: Int?, error: String?) { + do { + let documents = try Yams.load_all(yaml: yaml) + return (true, Array(documents).count, nil) + } catch { + return (false, nil, String(describing: error)) + } +} + +func issueObject(_ issue: PureYAML.Validation.Issue) -> [String: Any] { + [ + "severity": issue.severity.description, + "reason": issue.reason, + "path": issue.path.description, + "description": issue.description, + ] +} +EOF + +swift run \ + --package-path "$WORK_DIR" \ + YamsValidation \ + "$ARTIFACT_DIR/yams-validation.json"