From 906f0c454d6ee630b55de23754758a6cfe93243b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 5 Jun 2026 17:14:41 +0200 Subject: [PATCH] feat(emitting): dump YAML streams --- CHANGELOG.md | 2 + README.md | 8 +- Sources/API.swift | 8 ++ Sources/Emitting/Dumper.Stream.swift | 10 ++ Tests/DocumentationExampleTests.swift | 8 ++ Tests/StreamDumpingTests.swift | 179 ++++++++++++++++++++++++++ docs/DESIGN.md | 11 +- docs/MIGRATION.md | 3 +- docs/USAGE.md | 13 ++ 9 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 Sources/Emitting/Dumper.Stream.swift create mode 100644 Tests/StreamDumpingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7533e21..7721944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `PureYAML.Stream.Document`, document-indexed stream validation issues, and stream validation APIs that preserve document indexes without changing document-local validation paths. +- Add stream dumping for `[PureYAML.Stream.Document]` with deterministic + explicit document starts and existing emitter option support. - Add merge-key expansion for plain `<<` and explicit `!!merge` keys, including scalar merge mappings, sequence-of-mapping merge values, local override behavior, and exact errors for invalid merge values. diff --git a/README.md b/README.md index a75db2c..994f750 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,10 @@ 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, multi-document stream parsing, and a matching dumper with block and flow output -policies. It also includes path-aware validation for structural YAML checks such -as duplicate mapping keys, plus stream validation that preserves document -indexes. Callers that need explicit tag metadata can use `PureYAML.parseTagged(_:)` or +policies, including multi-document stream dumping with explicit document starts. +It also includes path-aware validation for structural YAML checks such as +duplicate mapping keys, plus stream validation that preserves document indexes. +Callers that need explicit tag metadata can use `PureYAML.parseTagged(_:)` or `PureYAML.parseTaggedStream(_:)` and run tagged validation over the preserved source-shaped node tree. @@ -137,6 +138,7 @@ title: First """) try PureYAML.validate(documents) +let streamYAML = PureYAML.dump(documents) ``` Typed conversion is available for scalar values, keyed mapping-backed structs, diff --git a/Sources/API.swift b/Sources/API.swift index c981dd0..af50fae 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -17,6 +17,14 @@ public extension PureYAML { Emitting.Dumper(options: options).dump(value) } + /// Serializes indexed YAML stream documents with explicit document starts. + static func dump( + _ documents: [Stream.Document], + options: Emitting.Options = .default, + ) -> String { + Emitting.Dumper(options: options).dump(documents) + } + /// Validates a parsed YAML value with the default validation rules. @discardableResult static func validate( diff --git a/Sources/Emitting/Dumper.Stream.swift b/Sources/Emitting/Dumper.Stream.swift new file mode 100644 index 0000000..efd5ee5 --- /dev/null +++ b/Sources/Emitting/Dumper.Stream.swift @@ -0,0 +1,10 @@ +public extension PureYAML.Emitting.Dumper { + /// Serializes indexed YAML stream documents with explicit document starts. + func dump(_ documents: [PureYAML.Stream.Document]) -> String { + documents + .map { document in + "---\n" + dump(document.value) + } + .joined() + } +} diff --git a/Tests/DocumentationExampleTests.swift b/Tests/DocumentationExampleTests.swift index 45d01c3..7b7c929 100644 --- a/Tests/DocumentationExampleTests.swift +++ b/Tests/DocumentationExampleTests.swift @@ -91,6 +91,14 @@ struct DocumentationExampleTests { ])), ]) #expect(try PureYAML.validate(documents).isEmpty) + #expect(PureYAML.dump(documents) == """ + --- + title: "First" + --- + - "Swift" + - "YAML" + + """) } @Test("README tagged parsing example stays executable") diff --git a/Tests/StreamDumpingTests.swift b/Tests/StreamDumpingTests.swift new file mode 100644 index 0000000..01b242a --- /dev/null +++ b/Tests/StreamDumpingTests.swift @@ -0,0 +1,179 @@ +@testable import PureYAML +import Testing + +@Suite("Stream Dumping") +struct StreamDumpingTests { + @Test("Dumps stream documents with explicit starts exactly") + func test_dumpsStreamDocumentsWithExplicitStartsExactly() throws { + let documents = [ + PureYAML.Stream.Document(index: 4, value: .mapping(.init([ + .init(key: "title", value: .string("First")), + ]))), + PureYAML.Stream.Document(index: 2, value: .sequence([ + .string("Swift"), + .string("YAML"), + ])), + ] + let yaml = PureYAML.dump(documents) + + #expect(yaml == """ + --- + title: "First" + --- + - "Swift" + - "YAML" + + """) + #expect(yaml.hasPrefix("---\n")) + #expect(documentStartCount(in: yaml) == 2) + #expect(!yaml.contains("document[4]")) + #expect(try PureYAML.parseStream(yaml).map(\.value) == documents.map(\.value)) + #expect(try PureYAML.validate(PureYAML.parseStream(yaml)).isEmpty) + } + + @Test("Dumps stream documents in array order rather than index order") + func test_dumpsStreamDocumentsInArrayOrderRatherThanIndexOrder() throws { + let documents = [ + PureYAML.Stream.Document(index: 10, value: .string("third")), + PureYAML.Stream.Document(index: 0, value: .string("first")), + PureYAML.Stream.Document(index: 5, value: .string("second")), + ] + let yaml = PureYAML.dump(documents) + + #expect(yaml == """ + --- + "third" + --- + "first" + --- + "second" + + """) + #expect(try PureYAML.parseStream(yaml).map(\.value) == [ + .string("third"), + .string("first"), + .string("second"), + ]) + #expect(!yaml.hasPrefix("---\n\"first\"")) + } + + @Test("Dumps empty stream as an empty string") + func test_dumpsEmptyStreamAsEmptyString() { + #expect(PureYAML.dump([PureYAML.Stream.Document]()) == "") + } + + @Test("Dumps stream null documents exactly") + func test_dumpsStreamNullDocumentsExactly() throws { + let documents = [ + PureYAML.Stream.Document(index: 0, value: .null), + PureYAML.Stream.Document(index: 1, value: .mapping(.init([ + .init(key: "empty", value: .null), + ]))), + ] + let yaml = PureYAML.dump(documents) + + #expect(yaml == """ + --- + null + --- + empty: null + + """) + #expect(try PureYAML.parseStream(yaml) == [ + .init(index: 0, value: .null), + .init(index: 1, value: .mapping(.init([ + .init(key: "empty", value: .null), + ]))), + ]) + } + + @Test("Dumps stream documents with flow output") + func test_dumpsStreamDocumentsWithFlowOutput() throws { + let documents = [ + PureYAML.Stream.Document(index: 0, value: .mapping(.init([ + .init(key: "tags", value: .sequence([ + .string("Swift"), + .string("YAML"), + ])), + ]))), + PureYAML.Stream.Document(index: 1, value: .sequence([ + .int(1), + .int(2), + ])), + ] + let options = PureYAML.Emitting.Options(collectionStyle: .flow) + let yaml = PureYAML.dump(documents, options: options) + + #expect(yaml == """ + --- + {"tags": ["Swift", "YAML"]} + --- + [1, 2] + + """) + #expect(!yaml.contains("\n - ")) + #expect(try PureYAML.parseStream(yaml).map(\.value) == documents.map(\.value)) + } + + @Test("Dumps stream documents with literal block scalar output") + func test_dumpsStreamDocumentsWithLiteralBlockScalarOutput() throws { + let documents = [ + PureYAML.Stream.Document(index: 0, value: .mapping(.init([ + .init(key: "body", value: .string("one\ntwo\n")), + ]))), + PureYAML.Stream.Document(index: 1, value: .string("alpha\nbeta")), + ] + let options = PureYAML.Emitting.Options(scalarStyle: .literalBlockWhenMultiline) + let yaml = PureYAML.dump(documents, options: options) + + #expect(yaml == """ + --- + body: | + one + two + --- + |- + alpha + beta + + """) + #expect(yaml.contains("body: |")) + #expect(yaml.contains("|-\n alpha")) + #expect(!yaml.contains("one\\ntwo")) + #expect(try PureYAML.parseStream(yaml).map(\.value) == documents.map(\.value)) + } + + @Test("Dumps stream documents with complex mapping keys") + func test_dumpsStreamDocumentsWithComplexMappingKeys() throws { + let value = PureYAML.Model.Value.mapping(.init([ + .init( + keyNode: .sequence([ + .string("Detroit Tigers"), + .string("Chicago Cubs"), + ]), + value: .sequence([ + .string("2001-07-23"), + ]), + ), + ])) + let documents = [ + PureYAML.Stream.Document(index: 0, value: value), + ] + let yaml = PureYAML.dump(documents) + + #expect(yaml == """ + --- + ? ["Detroit Tigers", "Chicago Cubs"] + : + - "2001-07-23" + + """) + #expect(yaml.contains("? [\"Detroit Tigers\", \"Chicago Cubs\"]")) + #expect(try PureYAML.parseStream(yaml).map(\.value) == [value]) + } +} + +private func documentStartCount(in yaml: String) -> Int { + yaml.split(separator: "\n", omittingEmptySubsequences: false) + .count(where: { $0 == "---" }) +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3b8f946..a37c41d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -131,6 +131,10 @@ can represent them. with `unsupportedMultiDocumentStream`. `PureYAML.parseStream(_:)` parses all documents in the stream, including empty explicit documents as `null`, and returns `PureYAML.Stream.Document` values with stable zero-based indexes. +`PureYAML.dump(_:)` accepts those stream documents and emits explicit `---` +document starts in the caller-provided array order. Each document body is +rendered through the same dumper used for single-document values, so block, +flow, literal scalar, and complex-key output policies stay centralized. `PureYAML.parseTagged(_:)` and `PureYAML.parseTaggedStream(_:)` reuse the same scanner and event parser but compose `PureYAML.Tagged.Node` values that preserve @@ -223,10 +227,9 @@ outside the library because it must own data-loss policy explicitly. Compatibility should be added in small, test-backed slices: -1. Multi-document stream dumping. -2. Additional built-in validation rules beyond duplicate-key behavior. -3. Broader Codable compatibility beyond scalar, keyed, and unkeyed containers. -4. Yams corpus comparison tests in a separate compatibility suite. +1. Additional built-in validation rules beyond duplicate-key behavior. +2. Broader Codable compatibility beyond scalar, keyed, and unkeyed containers. +3. Yams corpus comparison tests in a separate compatibility suite. The private `PureYAMLResearch` repository may be used to study Yams behavior, but the public implementation must be written in Swift and must not copy C parser diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 4349a53..bc60216 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -24,6 +24,7 @@ See [../ATTRIBUTION.md](../ATTRIBUTION.md) for the project attribution. | Compose all YAML documents in a stream | `PureYAML.parseStream(_:)` | Returns indexed `PureYAML.Stream.Document` values. | | Preserve explicit YAML tags for analysis | `PureYAML.parseTagged(_:)` or `PureYAML.parseTaggedStream(_:)` | Returns tag-preserving nodes without Foundation-backed construction. | | Serialize a YAML tree | `PureYAML.dump(_:options:)` | Emits deterministic YAML with explicit block, flow, and scalar policies. | +| Serialize all YAML documents in a stream | `PureYAML.dump(_:options:)` with `[PureYAML.Stream.Document]` | Emits deterministic explicit document starts and preserves array order. | | Decode a `Decodable` type from YAML | `PureYAML.decode(_:from:)` | Runs default validation before typed decoding. | | Encode an `Encodable` value to YAML | `PureYAML.encodeToYAML(_:options:)` | Encodes through `PureYAML.Model.Value`, then dumps. | | Validate document structure | `PureYAML.validate(_:using:strict:)` | Default rule reports duplicate mapping keys with exact paths. | @@ -75,6 +76,7 @@ add a fixture beside the closest existing suite. | Merge keys | Plain `<<` and explicit `!!merge` keys expand mapping values and sequence-of-mapping values. Local keys override inherited keys; duplicate local keys remain visible to validation. | `MergeKeyCompatibilityTests`, `ParsingCompatibilityTests`, `DownstreamDocumentTests` | | YAML directives and document markers | `%YAML 1.2`, selected `%TAG` expansion, `---`, and `...` are supported. | `ParsingCompatibilityTests`, `StreamParsingTests` | | Multi-document streams | `PureYAML.parseStream(_:)` parses implicit and explicit documents, empty explicit documents as `null`, `...` document ends, and trailing comments before the next `---`. | `StreamParsingTests` | +| Multi-document stream dumping | `PureYAML.dump(_:)` with stream documents emits explicit `---` before every document, preserves array order, and round-trips through `parseStream(_:)`. | `StreamDumpingTests` | | Stream validation | Stream validation preserves document indexes while keeping validation paths document-local. | `StreamParsingTests` | | Built-in scalar tags | `!!str`, `!!int`, `!!float`, `!!bool`, and `!!null` are applied when valid. | `ScalarCompatibilityFixtures` | | Tag-preserving parsing | `parseTagged(_:)` and `parseTaggedStream(_:)` preserve explicit scalar and collection tags for compatibility analysis. | `TaggedCompatibilityTests` | @@ -121,7 +123,6 @@ explicit fallback value tree. This avoids silent compatibility drift. Do not migrate a caller yet if it requires: -- multi-document stream dumping; - binary or timestamp conversion into Foundation types; - arbitrary `Any` load and dump APIs; - custom resolvers; diff --git a/docs/USAGE.md b/docs/USAGE.md index e62e640..54d7f1b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -47,6 +47,7 @@ Those tests are the executable contract for the short-form examples. | Parse a YAML stream while preserving explicit tags | `PureYAML.parseTaggedStream(_:)` | `[PureYAML.Tagged.Document]` | | Construct typed values from tagged nodes | `PureYAML.Tagged.Constructor` | Caller-owned output type | | Emit YAML from a value tree | `PureYAML.dump(_:options:)` | `String` | +| Emit YAML from stream documents | `PureYAML.dump(_:options:)` with `[PureYAML.Stream.Document]` | `String` | | Validate a value tree | `PureYAML.validate(_:using:strict:)` | `[PureYAML.Validation.Issue]` or a thrown issue collection | | Validate stream documents | `PureYAML.validate(_:using:strict:)` with `[PureYAML.Stream.Document]` | `[PureYAML.Stream.Issue]` or a thrown stream issue collection | | Decode a typed value from YAML | `PureYAML.decode(_:from:)` | `Decodable` value | @@ -90,6 +91,18 @@ title: First """) ``` +Use `PureYAML.dump(_:)` with `[PureYAML.Stream.Document]` to emit a +multi-document stream. The dumper emits explicit `---` before every document and +preserves the array order rather than sorting by `document.index`: + +```swift +let yaml = PureYAML.dump(documents) +``` + +The same emitter options apply to each document body. Flow collections, +literal-block scalar selection, and complex mapping key emission are delegated to +the single-document dumper. + `PureYAML.parse(_:)` remains the single-document API. It accepts one implicit or explicit document, including an explicit empty document, and throws `unsupportedMultiDocumentStream` when a second document is present.