diff --git a/CHANGELOG.md b/CHANGELOG.md index 3572ca2..1549082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add deliberately failing YAML fixtures that demonstrate exact validation diagnostics, strict failures, and non-strict warning returns. - Add non-throwing single-source and batch validation reports with exact parse - and validation diagnostics, optional warning-as-failure status, and Markdown - rendering for application-owned report files. + and validation diagnostics, optional warning-as-failure status, and Markdown, + YAML, and JSON rendering for application-owned report files. ### Changed @@ -56,6 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Harden real-world parsing for empty documents, indented root fragments, JSON-style flow mapping pairs, and tab-indented files encountered in corpus validation. +- Emit empty block collections as `[]` and `{}` so dumped YAML round-trips + without turning empty arrays or mappings into null values. ## [0.1.0] - 2026-06-05 diff --git a/README.md b/README.md index 7d66860..6f39f50 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ let issues = try PureYAML.validate(document, strict: false) For production-style validation over many YAML inputs, use the non-throwing report API. It validates every source, turns parse failures into diagnostics, -and can render Markdown that a consuming tool writes to disk: +and can render Markdown, YAML, or JSON that a consuming tool writes to disk: ```swift let report = PureYAML.validationReports([ @@ -215,6 +215,8 @@ let report = PureYAML.validationReports([ ]) let markdown = report.markdownDescription(title: "YAML Validation") +let yaml = report.yamlDescription(title: "YAML Validation") +let json = report.jsonDescription(title: "YAML Validation") ``` Custom validation rules can be layered onto the default validator or attached to diff --git a/Sources/Emitting/Dumper.Mapping.swift b/Sources/Emitting/Dumper.Mapping.swift index a5a38d5..8140949 100644 --- a/Sources/Emitting/Dumper.Mapping.swift +++ b/Sources/Emitting/Dumper.Mapping.swift @@ -3,7 +3,12 @@ extension PureYAML.Emitting.Dumper { _ mapping: PureYAML.Model.Mapping, indent: Int, ) -> [String] { - mapping.pairs.flatMap { pair in + let prefix = String(repeating: " ", count: indent) + guard !mapping.pairs.isEmpty else { + return [prefix + "{}"] + } + + return mapping.pairs.flatMap { pair in renderMappingPair(pair, indent: indent) } } @@ -25,6 +30,10 @@ extension PureYAML.Emitting.Dumper { indent: Int, ) -> [String] { let prefix = String(repeating: " ", count: indent) + if let emptyCollection = renderEmptyCollectionLiteral(pair.value) { + return [prefix + escapeKey(pair.key) + ": " + emptyCollection] + } + switch pair.value { case .mapping, .sequence: return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2) @@ -38,6 +47,11 @@ extension PureYAML.Emitting.Dumper { indent: Int, ) -> [String] { let prefix = String(repeating: " ", count: indent) + if let emptyCollection = renderEmptyCollectionLiteral(pair.value) { + return [prefix + "? " + pair.keyNode.flowDescription] + + [prefix + ": " + emptyCollection] + } + return [prefix + "? " + pair.keyNode.flowDescription] + [prefix + ":"] + render(pair.value, indent: indent + 2) diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index 6de3d37..6f07c78 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -38,12 +38,20 @@ extension PureYAML.Emitting.Dumper { indent: Int, ) -> [String] { let prefix = String(repeating: " ", count: indent) + guard !values.isEmpty else { + return [prefix + "[]"] + } + return values.flatMap { value in + if let emptyCollection = renderEmptyCollectionLiteral(value) { + return [prefix + "- " + emptyCollection] + } + switch value { case .mapping, .sequence: - [prefix + "-"] + render(value, indent: indent + 2) + return [prefix + "-"] + render(value, indent: indent + 2) default: - renderSequenceScalarItem(value, indent: indent) + return renderSequenceScalarItem(value, indent: indent) } } } @@ -75,6 +83,17 @@ extension PureYAML.Emitting.Dumper { return "[\(items)]" } + func renderEmptyCollectionLiteral(_ value: PureYAML.Model.Value) -> String? { + switch value { + case let .mapping(mapping) where mapping.pairs.isEmpty: + "{}" + case let .sequence(values) where values.isEmpty: + "[]" + default: + nil + } + } + func renderFlowScalar(_ value: PureYAML.Model.Value) -> String { switch value { case .null: diff --git a/Sources/Validation/Validation.Report.swift b/Sources/Validation/Validation.Report.swift index f795926..2218f2d 100644 --- a/Sources/Validation/Validation.Report.swift +++ b/Sources/Validation/Validation.Report.swift @@ -41,6 +41,10 @@ public extension PureYAML.Validation { public var description: String { diagnostics.map(\.description).joined(separator: "\n") } + + public func modelValue() -> PureYAML.Model.Value { + .sequence(diagnostics.map(\.modelValue)) + } } /// Validation result for one named source in a batch. @@ -54,6 +58,15 @@ public extension PureYAML.Validation { self.report = report self.invalid = invalid } + + public func modelValue() -> PureYAML.Model.Value { + .mapping(.init([ + .init(key: "name", value: .string(name)), + .init(key: "status", value: .string(invalid ? "invalid" : "valid")), + .init(key: "invalid", value: .bool(invalid)), + .init(key: "diagnostics", value: report.modelValue()), + ])) + } } /// Non-throwing aggregate report for validating many YAML inputs. @@ -132,6 +145,149 @@ public extension PureYAML.Validation { return lines.joined(separator: "\n") } + + public func modelValue( + title: String = "PureYAML Validation Report", + ) -> PureYAML.Model.Value { + .mapping(.init([ + .init(key: "title", value: .string(title)), + .init(key: "summary", value: summaryModelValue), + .init(key: "sources", value: .sequence(sourceReports.map { $0.modelValue() })), + ])) + } + + public func yamlDescription( + title: String = "PureYAML Validation Report", + ) -> String { + PureYAML.dump(modelValue(title: title)) + } + + public func jsonDescription( + title: String = "PureYAML Validation Report", + ) -> String { + modelValue(title: title).jsonDescription + "\n" + } + + private var summaryModelValue: PureYAML.Model.Value { + .mapping(.init([ + .init(key: "files", value: .int(fileCount)), + .init(key: "valid", value: .int(validCount)), + .init(key: "invalid", value: .int(invalidCount)), + .init(key: "diagnostics", value: .int(diagnosticCount)), + .init(key: "warningsFail", value: .bool(failOnWarnings)), + ])) + } + } +} + +private extension PureYAML.Validation.Diagnostic { + var modelValue: PureYAML.Model.Value { + .mapping(.init([ + .init(key: "kind", value: .string(kind.description)), + .init(key: "severity", value: .string(severity.description)), + .init(key: "file", value: file.map(PureYAML.Model.Value.string) ?? .null), + .init(key: "documentIndex", value: documentIndex.map(PureYAML.Model.Value.int) ?? .null), + .init(key: "path", value: path.map { .string($0.isRoot ? "root" : $0.description) } ?? .null), + .init(key: "reason", value: .string(reason)), + .init(key: "description", value: .string(description)), + ])) + } +} + +private extension PureYAML.Model.Value { + var jsonDescription: String { + jsonDescription(indentation: 0) + } + + func jsonDescription(indentation: Int) -> String { + switch self { + case .null: + "null" + case let .bool(value): + value ? "true" : "false" + case let .int(value): + String(value) + case let .double(value): + value.isFinite ? String(value) : "null" + case let .string(value): + value.jsonEscaped + case let .sequence(values): + values.jsonDescription(indentation: indentation) + case let .mapping(mapping): + mapping.jsonDescription(indentation: indentation) + } + } +} + +private extension [PureYAML.Model.Value] { + func jsonDescription(indentation: Int) -> String { + guard !isEmpty else { + return "[]" + } + + let nextIndentation = indentation + 2 + let lines = map { value in + "\(String.spaces(nextIndentation))\(value.jsonDescription(indentation: nextIndentation))" + } + return "[\n\(lines.joined(separator: ",\n"))\n\(String.spaces(indentation))]" + } +} + +private extension PureYAML.Model.Mapping { + func jsonDescription(indentation: Int) -> String { + guard !pairs.isEmpty else { + return "{}" + } + + let nextIndentation = indentation + 2 + let lines = pairs.map { pair in + let value = pair.value.jsonDescription(indentation: nextIndentation) + return "\(String.spaces(nextIndentation))\(pair.key.jsonEscaped): \(value)" + } + return "{\n\(lines.joined(separator: ",\n"))\n\(String.spaces(indentation))}" + } +} + +private extension String { + static func spaces(_ count: Int) -> String { + String(repeating: " ", count: count) + } + + var jsonEscaped: String { + var output = "\"" + for scalar in unicodeScalars { + switch scalar.value { + case 0x08: + output += "\\b" + case 0x09: + output += "\\t" + case 0x0A: + output += "\\n" + case 0x0C: + output += "\\f" + case 0x0D: + output += "\\r" + case 0x22: + output += "\\\"" + case 0x5C: + output += "\\\\" + case 0x00 ... 0x1F: + output += scalar.value.jsonControlEscape + default: + output.unicodeScalars.append(scalar) + } + } + output += "\"" + return output + } +} + +private extension UInt32 { + var jsonControlEscape: String { + let digits = Array("0123456789abcdef") + return "\\u" + stride(from: 12, through: 0, by: -4).map { shift in + String(digits[Int((self >> UInt32(shift)) & 0xF)]) + }.joined() } } diff --git a/Tests/CodableCompatibilityTests.swift b/Tests/CodableCompatibilityTests.swift index 2682f05..36f0600 100644 --- a/Tests/CodableCompatibilityTests.swift +++ b/Tests/CodableCompatibilityTests.swift @@ -72,7 +72,7 @@ struct CodableCompatibilityTests { - "two" - heading: "Outro" - items: + items: [] """) #expect(yaml.contains("sections:")) diff --git a/Tests/DumperEmptyCollectionTests.swift b/Tests/DumperEmptyCollectionTests.swift new file mode 100644 index 0000000..6a2822e --- /dev/null +++ b/Tests/DumperEmptyCollectionTests.swift @@ -0,0 +1,36 @@ +@testable import PureYAML +import Testing + +@Suite("Dumper Empty Collections") +struct DumperEmptyCollectionTests { + @Test("Dumps empty collections") + func test_emptyCollections() throws { + #expect(PureYAML.dump(.mapping(.init())) == "{}\n") + #expect(PureYAML.dump(.sequence([])) == "[]\n") + #expect(try PureYAML.parse(PureYAML.dump(.mapping(.init()))) == .mapping(.init())) + #expect(try PureYAML.parse(PureYAML.dump(.sequence([]))) == .sequence([])) + + let nested = PureYAML.Model.Value.mapping(.init([ + .init(key: "mapping", value: .mapping(.init())), + .init(key: "sequence", value: .sequence([])), + .init(key: "items", value: .sequence([ + .sequence([]), + .mapping(.init()), + ])), + ])) + + #expect(PureYAML.dump(nested) == """ + mapping: {} + sequence: [] + items: + - [] + - {} + + """) + #expect(try PureYAML.parse(PureYAML.dump(nested)) == nested) + + let options = PureYAML.Emitting.Options(collectionStyle: .flow) + #expect(PureYAML.dump(.mapping(.init()), options: options) == "{}\n") + #expect(PureYAML.dump(.sequence([]), options: options) == "[]\n") + } +} diff --git a/Tests/DumperTests.swift b/Tests/DumperTests.swift index 2fa1415..4f4d8a4 100644 --- a/Tests/DumperTests.swift +++ b/Tests/DumperTests.swift @@ -91,16 +91,6 @@ struct DumperTests { #expect(try PureYAML.parse(PureYAML.dump(original)) == original) } - @Test("Dumps empty collections") - func test_emptyCollections() { - #expect(PureYAML.dump(.mapping(.init())) == "\n") - #expect(PureYAML.dump(.sequence([])) == "\n") - - let options = PureYAML.Emitting.Options(collectionStyle: .flow) - #expect(PureYAML.dump(.mapping(.init()), options: options) == "{}\n") - #expect(PureYAML.dump(.sequence([]), options: options) == "[]\n") - } - @Test("Dumps plain strings only when selected and safe") func test_plainStringScalarPolicy() throws { let value = PureYAML.Model.Value.mapping(.init([ diff --git a/Tests/UnkeyedCodingTests.swift b/Tests/UnkeyedCodingTests.swift index 32dfbd8..4a83d30 100644 --- a/Tests/UnkeyedCodingTests.swift +++ b/Tests/UnkeyedCodingTests.swift @@ -73,7 +73,7 @@ struct UnkeyedCodingTests { - 2 """) - #expect(try PureYAML.encodeToYAML([Int]()) == "\n") + #expect(try PureYAML.encodeToYAML([Int]()) == "[]\n") } @Test("Unkeyed super encoders reserve distinct indexes before values are written") diff --git a/Tests/UnkeyedEncodingFixtures.swift b/Tests/UnkeyedEncodingFixtures.swift index 4e3746a..960cf03 100644 --- a/Tests/UnkeyedEncodingFixtures.swift +++ b/Tests/UnkeyedEncodingFixtures.swift @@ -99,7 +99,7 @@ let sequenceEncodeFixtures: [SequenceEncodeFixture] = [ - - 1 - 2 - - + - [] """, forbiddenYAMLFragments: ["null"], @@ -140,7 +140,7 @@ let sequenceEncodeFixtures: [SequenceEncodeFixture] = [ featured: - "Typed YAML" - null - empty: + empty: [] """, forbiddenYAMLFragments: ["missing:", "- 0"], diff --git a/Tests/ValidationReportTests.swift b/Tests/ValidationReportTests.swift index becbb43..08e04a5 100644 --- a/Tests/ValidationReportTests.swift +++ b/Tests/ValidationReportTests.swift @@ -177,6 +177,27 @@ struct ValidationReportTests { #expect(try writeAndRead(report: report) == expected) } + + @Test("Batch reports render machine-readable YAML and JSON files") + func test_batchReportsRenderMachineReadableYAMLAndJSONFiles() throws { + let report = makeMarkdownValidationReport() + let title = "YAML Production Validation" + + #expect(report.modelValue(title: title) == expectedReportModelValue) + #expect(try PureYAML.parse(report.yamlDescription(title: title)) == expectedReportModelValue) + #expect(try jsonSummary(report.jsonDescription(title: title)) == [ + "diagnostics": 2, + "files": 3, + "invalid": 2, + "valid": 1, + ]) + + let outputs = try writeAndReadMachineReports(report: report, title: title) + + #expect(try PureYAML.parse(outputs.yaml) == expectedReportModelValue) + #expect(outputs.json == report.jsonDescription(title: title)) + #expect(outputs.json.contains(#""reason": "Duplicate mapping key 'title'""#)) + } } private func makeMarkdownValidationReport() -> PureYAML.Validation.BatchReport { @@ -211,6 +232,46 @@ private func writeAndRead(report: PureYAML.Validation.BatchReport) throws -> Str return try String(contentsOf: file, encoding: .utf8) } +private func writeAndReadMachineReports( + report: PureYAML.Validation.BatchReport, + title: String, +) throws -> (yaml: String, json: String) { + let directory = try temporaryValidationReportDirectory() + defer { + try? FileManager.default.removeItem(at: directory) + } + + let yamlFile = directory.appendingPathComponent("validation-report.yaml") + let jsonFile = directory.appendingPathComponent("validation-report.json") + try report.yamlDescription(title: title).write(to: yamlFile, atomically: true, encoding: .utf8) + try report.jsonDescription(title: title).write(to: jsonFile, atomically: true, encoding: .utf8) + return try ( + String(contentsOf: yamlFile, encoding: .utf8), + String(contentsOf: jsonFile, encoding: .utf8) + ) +} + +private func temporaryValidationReportDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "PureYAMLValidationReport-\(UUID().uuidString)", + isDirectory: true, + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} + +private func jsonSummary(_ json: String) throws -> [String: Int] { + let data = Data(json.utf8) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let summary = try #require(object["summary"] as? [String: Any]) + return try [ + "diagnostics": #require(summary["diagnostics"] as? Int), + "files": #require(summary["files"] as? Int), + "invalid": #require(summary["invalid"] as? Int), + "valid": #require(summary["valid"] as? Int), + ] +} + private let expectedMarkdownValidationReport = """ # YAML Production Validation @@ -243,3 +304,62 @@ duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key ``` """ + +private let expectedReportModelValue = PureYAML.Model.Value.mapping(.init([ + .init(key: "title", value: .string("YAML Production Validation")), + .init(key: "summary", value: .mapping(.init([ + .init(key: "files", value: .int(3)), + .init(key: "valid", value: .int(1)), + .init(key: "invalid", value: .int(2)), + .init(key: "diagnostics", value: .int(2)), + .init(key: "warningsFail", value: .bool(false)), + ]))), + .init(key: "sources", value: .sequence([ + .mapping(.init([ + .init(key: "name", value: .string("valid.yaml")), + .init(key: "status", value: .string("valid")), + .init(key: "invalid", value: .bool(false)), + .init(key: "diagnostics", value: .sequence([])), + ])), + .mapping(.init([ + .init(key: "name", value: .string("broken.yaml")), + .init(key: "status", value: .string("invalid")), + .init(key: "invalid", value: .bool(true)), + .init(key: "diagnostics", value: .sequence([ + .mapping(.init([ + .init(key: "kind", value: .string("parse")), + .init(key: "severity", value: .string("error")), + .init(key: "file", value: .string("broken.yaml")), + .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"), + ), + ])), + ])), + ])), + .mapping(.init([ + .init(key: "name", value: .string("duplicates.yaml")), + .init(key: "status", value: .string("invalid")), + .init(key: "invalid", value: .bool(true)), + .init(key: "diagnostics", value: .sequence([ + .mapping(.init([ + .init(key: "kind", value: .string("validation")), + .init(key: "severity", value: .string("error")), + .init(key: "file", value: .string("duplicates.yaml")), + .init(key: "documentIndex", value: .int(0)), + .init(key: "path", value: .string("$.title")), + .init(key: "reason", value: .string("Duplicate mapping key 'title'")), + .init( + key: "description", + value: .string( + "duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'", + ), + ), + ])), + ])), + ])), + ])), +])) diff --git a/docs/USAGE.md b/docs/USAGE.md index 32361c2..39b73e8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -293,10 +293,13 @@ let report = PureYAML.validationReports( ) let markdown = report.markdownDescription(title: "YAML Validation") +let yaml = report.yamlDescription(title: "YAML Validation") +let json = report.jsonDescription(title: "YAML Validation") ``` PureYAML does not write files from the library target. Applications that need a -validation document should write `markdown` through their own file IO layer. +validation document should write `markdown`, `yaml`, or `json` through their +own file IO layer. ## Cross-Platform Verification