Add machine-readable validation reports

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 20:39:32 +02:00
parent 0e92e31d89
commit 13183a4353
12 changed files with 363 additions and 21 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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()
}
}

View File

@ -72,7 +72,7 @@ struct CodableCompatibilityTests {
- "two"
-
heading: "Outro"
items:
items: []
""")
#expect(yaml.contains("sections:"))

View File

@ -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")
}
}

View File

@ -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([

View File

@ -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")

View File

@ -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"],

View File

@ -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'",
),
),
])),
])),
])),
])),
]))

View File

@ -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