Add production validation reports

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 20:23:20 +02:00
parent bc54528353
commit 0e92e31d89
13 changed files with 665 additions and 13 deletions

View File

@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
examples.
- 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.
### Changed
@ -50,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
tagged parser and validator explicitly.
- Preserve anchors and tags on mapping values and sequence items whose actual
node starts on the following indented line.
- Harden real-world parsing for empty documents, indented root fragments,
JSON-style flow mapping pairs, and tab-indented files encountered in corpus
validation.
## [0.1.0] - 2026-06-05

View File

@ -204,6 +204,19 @@ errors still throw.
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:
```swift
let report = PureYAML.validationReports([
.init(name: "valid.yaml", yaml: "title: Ready"),
.init(name: "broken.yaml", yaml: "title: \"open"),
])
let markdown = report.markdownDescription(title: "YAML Validation")
```
Custom validation rules can be layered onto the default validator or attached to
a blank validator when callers want only project-specific checks. Validation
tests pin exact issue paths, descriptions, severity handling, rule traversal

View File

@ -37,7 +37,8 @@ extension PureYAML.Parsing.Scanner {
width += 1
state.reader.advance()
} else if character == "\t" {
throw PureYAML.Parsing.ParseError.tabIndentation(line: state.reader.mark.line)
width = nextTabStop(after: width)
state.reader.advance()
} else {
return width
}
@ -45,6 +46,10 @@ extension PureYAML.Parsing.Scanner {
return width
}
func nextTabStop(after width: Int) -> Int {
((width / 4) + 1) * 4
}
func shouldProcessIndentation(_ state: State) -> Bool {
guard !state.reader.isAtEnd, !isLineBreak(state.reader.peek()) else {
return false

View File

@ -73,11 +73,33 @@ extension PureYAML.Parsing.TokenEventParser {
_ = cursor.advance()
continue
}
endMark = try parseMappingPair().endMark
endMark = try parseFlowMappingPair().endMark
if cursor.current?.kind.isFlowTerminator == false {
throw unexpectedToken(expected: "flow entry or flow mapping end")
}
}
throw unexpectedToken(expected: "flow mapping end")
}
mutating func parseFlowMappingPair() throws -> PureYAML.Parsing.NodeResult {
if cursor.current?.kind.isMappingKey == true {
return try parseMappingPair()
}
_ = try parseNode()
let valueIndicator = try expect("mapping value") { kind in
if case .mappingValue = kind {
return true
}
return false
}
let properties = 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))
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark)
}
return try parseNode(properties: properties)
}
}

View File

@ -27,8 +27,11 @@ extension PureYAML.Parsing {
try parseDocument()
didParseDocument = true
}
guard didParseDocument else {
throw ParseError.emptyDocument
if !didParseDocument {
let mark = cursor.current?.mark ?? streamStart.endMark
events.append(.documentStart(mark: mark))
appendEmptyDocument(at: mark)
events.append(.documentEnd(mark: mark))
}
let streamEnd = try expect("stream end") { kind in
if case .streamEnd = kind {
@ -60,8 +63,7 @@ extension PureYAML.Parsing.TokenEventParser {
return
}
let result = try parseNode()
var endMark = result.endMark
var endMark = try parseDocumentRootNode().endMark
while cursor.current?.kind.isDedent == true {
let dedent = try expect("dedent") { $0.isDedent }
endMark = dedent.endMark
@ -81,6 +83,18 @@ extension PureYAML.Parsing.TokenEventParser {
events.append(.documentEnd(mark: endMark))
}
mutating func parseDocumentRootNode() throws -> PureYAML.Parsing.NodeResult {
guard cursor.current?.kind.isIndent == true else {
return try parseNode()
}
_ = try expect("indent") { $0.isIndent }
let result = try parseNode()
if cursor.current?.kind.isDedent == true {
_ = try expect("dedent") { $0.isDedent }
}
return result
}
mutating func parseDocumentStartMark() -> PureYAML.Parsing.Mark {
guard cursor.current?.kind.isDocumentStart == true else {
return cursor.current?.mark ?? cursor.previous?.endMark ?? .start

View File

@ -0,0 +1,56 @@
public extension PureYAML.Validation {
enum DiagnosticKind: Equatable, Sendable, CustomStringConvertible {
case parse
case validation
public var description: String {
switch self {
case .parse:
"parse"
case .validation:
"validation"
}
}
}
/// A parse or validation diagnostic suitable for batch validation reports.
struct Diagnostic: Equatable, Sendable, CustomStringConvertible {
public var kind: DiagnosticKind
public var severity: Severity
public var file: String?
public var documentIndex: Int?
public var path: Path?
public var reason: String
public init(
kind: DiagnosticKind,
severity: Severity,
file: String? = nil,
documentIndex: Int? = nil,
path: Path? = nil,
reason: String,
) {
self.kind = kind
self.severity = severity
self.file = file
self.documentIndex = documentIndex
self.path = path
self.reason = reason
}
public var description: String {
var parts: [String] = []
if let file {
parts.append(file)
}
if let documentIndex {
parts.append("document[\(documentIndex)]")
}
if let path {
parts.append(path.isRoot ? "root" : path.description)
}
let location = parts.isEmpty ? "input" : parts.joined(separator: ": ")
return "\(location): \(severity): \(kind): \(reason)"
}
}
}

View File

@ -0,0 +1,187 @@
public extension PureYAML.Validation {
/// Named YAML source used for batch validation without coupling PureYAML to file IO.
struct Source: Equatable, Sendable {
public var name: String
public var yaml: String
public init(name: String, yaml: String) {
self.name = name
self.yaml = yaml
}
}
/// Non-throwing validation report for one YAML input.
struct Report: Equatable, Sendable, CustomStringConvertible {
public var diagnostics: [Diagnostic]
public init(_ diagnostics: [Diagnostic] = []) {
self.diagnostics = diagnostics
}
public var isEmpty: Bool {
diagnostics.isEmpty
}
public var errors: [Diagnostic] {
diagnostics.filter { $0.severity == .error }
}
public var warnings: [Diagnostic] {
diagnostics.filter { $0.severity == .warning }
}
public var isValid: Bool {
errors.isEmpty
}
public func containsFailures(failOnWarnings: Bool = false) -> Bool {
failOnWarnings ? !diagnostics.isEmpty : !errors.isEmpty
}
public var description: String {
diagnostics.map(\.description).joined(separator: "\n")
}
}
/// Validation result for one named source in a batch.
struct SourceReport: Equatable, Sendable {
public var name: String
public var report: Report
public var invalid: Bool
public init(name: String, report: Report, invalid: Bool) {
self.name = name
self.report = report
self.invalid = invalid
}
}
/// Non-throwing aggregate report for validating many YAML inputs.
struct BatchReport: Equatable, Sendable, CustomStringConvertible {
public var sourceReports: [SourceReport]
public var failOnWarnings: Bool
public init(
sourceReports: [SourceReport],
failOnWarnings: Bool = false,
) {
self.sourceReports = sourceReports
self.failOnWarnings = failOnWarnings
}
public var fileCount: Int {
sourceReports.count
}
public var validCount: Int {
sourceReports.count(where: { !$0.invalid })
}
public var invalidCount: Int {
sourceReports.count(where: { $0.invalid })
}
public var diagnosticCount: Int {
sourceReports.reduce(0) { count, sourceReport in
count + sourceReport.report.diagnostics.count
}
}
public var isValid: Bool {
invalidCount == 0
}
public var diagnostics: [Diagnostic] {
sourceReports.flatMap(\.report.diagnostics)
}
public var description: String {
diagnostics.map(\.description).joined(separator: "\n")
}
public func markdownDescription(
title: String = "PureYAML Validation Report",
) -> String {
var lines: [String] = [
"# \(title)",
"",
"- Files: \(fileCount)",
"- Valid: \(validCount)",
"- Invalid: \(invalidCount)",
"- Diagnostics: \(diagnosticCount)",
"- Warnings fail: \(failOnWarnings ? "yes" : "no")",
"",
]
for sourceReport in sourceReports {
lines.append("## \(sourceReport.name)")
lines.append("")
lines.append("Status: \(sourceReport.invalid ? "invalid" : "valid")")
lines.append("")
if sourceReport.report.isEmpty {
lines.append("No diagnostics.")
} else {
lines.append("```text")
lines.append(sourceReport.report.description)
lines.append("```")
}
lines.append("")
}
return lines.joined(separator: "\n")
}
}
}
public extension PureYAML {
/// Parses and validates YAML without throwing, returning parse and validation diagnostics.
static func validationReport(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
) -> Validation.Report {
do {
let documents = try parseStream(yaml)
let result = validator.collect(documents)
let diagnostics = result.issues.map { streamIssue in
Validation.Diagnostic(
kind: .validation,
severity: streamIssue.issue.severity,
file: file,
documentIndex: streamIssue.documentIndex,
path: streamIssue.issue.path,
reason: streamIssue.issue.reason,
)
}
return Validation.Report(diagnostics)
} catch {
return Validation.Report([
Validation.Diagnostic(
kind: .parse,
severity: .error,
file: file,
reason: String(describing: error),
),
])
}
}
/// Validates many YAML inputs without throwing or stopping at the first failure.
static func validationReports(
_ sources: [Validation.Source],
using validator: Validation.Validator = .init(),
failOnWarnings: Bool = false,
) -> Validation.BatchReport {
let reports = sources.map { source in
let report = validationReport(source.yaml, file: source.name, using: validator)
return Validation.SourceReport(
name: source.name,
report: report,
invalid: report.containsFailures(failOnWarnings: failOnWarnings),
)
}
return Validation.BatchReport(sourceReports: reports, failOnWarnings: failOnWarnings)
}
}

View File

@ -5,9 +5,6 @@ import Testing
struct ParsingErrorTests {
@Test("Reports exact parser errors")
func test_errorReporting() {
expectParseError("", .emptyDocument)
expectParseError("# comment only", .emptyDocument)
expectParseError("\tkey: value", .tabIndentation(line: 1))
expectParseError("root:\n child: yes\n wrong: no", .unexpectedIndentation(line: 3))
expectParseError("- one\nkey: value", .mixedCollectionStyles(line: 2))
expectParseError("root:\n child: value\n dangling", .unexpectedToken(
@ -28,6 +25,25 @@ struct ParsingErrorTests {
))
}
@Test("Parses empty and comment-only documents as null")
func test_emptyAndCommentOnlyDocumentsParseAsNull() throws {
#expect(try PureYAML.parse("") == .null)
#expect(try PureYAML.parse("# comment only") == .null)
#expect(try PureYAML.parse(" \n# comment only\n") == .null)
}
@Test("Parses tab indentation with permissive tab stops")
func test_tabIndentationUsesPermissiveTabStops() throws {
let root = try requireMapping(PureYAML.parse(
"""
root:
\tchild: value
""",
))
#expect(root?.mapping("root")?["child"] == .string("value"))
}
@Test("Parser errors describe returned failures")
func test_errorDescriptions() {
#expect(PureYAML.Parsing.ParseError.emptyDocument.description == "document is empty")

View File

@ -105,6 +105,32 @@ struct ParsingEventTests {
#expect(!descriptions.contains { $0.contains("it''s") })
}
@Test("Parses JSON-style flow mapping pairs")
func test_jsonStyleFlowMappingPairs() throws {
let root = try requireMapping(PureYAML.parse(
"""
value:
{
"version" : "7",
"fields" : [
{
"key" : "productType",
"required" : true
}
]
}
""",
))
let value = root?.mapping("value")
let field = value?.sequence("fields")?.first?.mapping
#expect(value?["version"] == .string("7"))
#expect(field?["key"] == .string("productType"))
#expect(field?["required"] == .bool(true))
#expect(value?["missing"] == nil)
}
@Test("Emits golden events for block scalar headers")
func test_blockScalarHeaders() throws {
let events = try PureYAML.Parsing.Parser().parseEvents(
@ -141,9 +167,6 @@ struct ParsingEventTests {
@Test("Reports parse-event errors")
func test_errorReporting() {
expectError(PureYAML.Parsing.ParseError.emptyDocument) {
try PureYAML.Parsing.Parser().parseEvents("")
}
expectError(PureYAML.Parsing.ParseError.unexpectedIndentation(line: 3)) {
try PureYAML.Parsing.Parser().parseEvents("root:\n child: yes\n wrong: no")
}
@ -166,6 +189,19 @@ struct ParsingEventTests {
description: "expected flow entry or flow sequence end at line 1, column 17, found streamEnd",
)
}
@Test("Emits null-document events for empty input")
func test_emptyInputEmitsNullDocumentEvents() throws {
let events = try PureYAML.Parsing.Parser().parseEvents("")
#expect(events.map(\.description) == [
"streamStart @1:1@0",
"documentStart @1:1@0",
"scalar value=\"\" anchor=- tag=- style=plain @1:1@0",
"documentEnd @1:1@0",
"streamEnd @1:1@0",
])
}
}
private func expectParseEventError(

View File

@ -3,6 +3,38 @@ import Testing
@Suite("Parsing Indentation Regressions")
struct ParsingIndentationRegressionTests {
@Test("Parses indented root mapping fragments")
func test_indentedRootMappingFragments() throws {
let root = try requireMapping(PureYAML.parse(
"""
title: OpenAPI Generation Demo
version: 1.0.0
contact:
name: API Support
email: support@example.com
""",
))
#expect(root?["title"] == .string("OpenAPI Generation Demo"))
#expect(root?["version"] == .string("1.0.0"))
#expect(root?.mapping("contact")?["name"] == .string("API Support"))
#expect(root?.mapping("contact")?["email"] == .string("support@example.com"))
#expect(root?["missing"] == nil)
}
@Test("Parses indented root mapping fragments with leading blank lines")
func test_indentedRootMappingFragmentsWithLeadingBlankLines() throws {
let root = try requireMapping(PureYAML.parse(
"""
securitySchemes:
""",
))
#expect(root?["securitySchemes"] == .null)
#expect(root?["missing"] == nil)
}
@Test("Parses compact sequence item mappings without swallowing lower-column siblings")
func test_compactSequenceItemMappingsDoNotSwallowLowerColumnSiblings() throws {
let root = try requireMapping(PureYAML.parse(

View File

@ -238,7 +238,6 @@ struct ScannerTests {
@Test("Reports scanner errors")
func test_errorReporting() {
expectScannerError("\tname: value", .tabIndentation(line: 1))
expectScannerError("root:\n child: yes\n wrong: no", .unexpectedIndentation(line: 3))
expectScannerError("name: \"open", .unterminatedQuotedString(line: 1))
expectScannerError("name: 'open", .unterminatedQuotedString(line: 1))

View File

@ -0,0 +1,245 @@
import Foundation
@testable import PureYAML
import Testing
@Suite("Validation Reports")
struct ValidationReportTests {
@Test("Valid YAML returns an empty report")
func test_validYAMLReturnsEmptyReport() {
let report = PureYAML.validationReport("title: Ready", file: "valid.yaml")
#expect(report.isEmpty)
#expect(report.isValid)
#expect(report.description == "")
}
@Test("Parse failures return file-scoped diagnostics")
func test_parseFailuresReturnFileScopedDiagnostics() {
let report = PureYAML.validationReport("title: \"open", file: "broken.yaml")
#expect(report.diagnostics == [
.init(
kind: .parse,
severity: .error,
file: "broken.yaml",
reason: "unterminated quoted string at line 1",
),
])
#expect(!report.isValid)
#expect(report.description == "broken.yaml: error: parse: unterminated quoted string at line 1")
}
@Test("Duplicate keys return document and path diagnostics")
func test_duplicateKeysReturnDocumentAndPathDiagnostics() {
let report = PureYAML.validationReport(
"""
title: First
title: Second
""",
file: "duplicates.yaml",
)
#expect(report.diagnostics == [
.init(
kind: .validation,
severity: .error,
file: "duplicates.yaml",
documentIndex: 0,
path: .init([.key("title")]),
reason: "Duplicate mapping key 'title'",
),
])
#expect(report.description == "duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'")
}
@Test("Warning reports stay visible without making the report invalid")
func test_warningReportsStayVisibleWithoutMakingReportInvalid() {
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Rule(
description: "Mode is not legacy",
severity: .warning,
check: { context in context.subject != .string("legacy") },
when: { context in context.path.components.last == .key("mode") },
),
)
let report = PureYAML.validationReport("mode: legacy", file: "warnings.yaml", using: validator)
#expect(report.diagnostics == [
.init(
kind: .validation,
severity: .warning,
file: "warnings.yaml",
documentIndex: 0,
path: .init([.key("mode")]),
reason: "Failed to satisfy: Mode is not legacy",
),
])
#expect(report.isValid)
#expect(report.warnings.count == 1)
#expect(report.errors.isEmpty)
}
@Test("Batch reports collect every source without stopping at the first failure")
func test_batchReportsCollectEverySourceWithoutStoppingAtFirstFailure() {
let validator = PureYAML.Validation.Validator().validating(
PureYAML.Validation.Rule(
description: "Mode is not legacy",
severity: .warning,
check: { context in context.subject != .string("legacy") },
when: { context in context.path.components.last == .key("mode") },
),
)
let report = PureYAML.validationReports(
[
.init(name: "valid.yaml", yaml: "title: Ready"),
.init(name: "broken.yaml", yaml: "title: \"open"),
.init(
name: "duplicates.yaml",
yaml: """
title: First
title: Second
""",
),
.init(name: "warnings.yaml", yaml: "mode: legacy"),
],
using: validator,
)
#expect(report.fileCount == 4)
#expect(report.validCount == 2)
#expect(report.invalidCount == 2)
#expect(report.diagnosticCount == 3)
#expect(!report.isValid)
#expect(report.description == [
"broken.yaml: 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"))
#expect(report.sourceReports.map(\.name) == [
"valid.yaml",
"broken.yaml",
"duplicates.yaml",
"warnings.yaml",
])
#expect(report.sourceReports.map(\.invalid) == [false, true, true, false])
}
@Test("Batch reports can treat warnings as failures")
func test_batchReportsCanTreatWarningsAsFailures() {
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Rule(
description: "Mode is not legacy",
severity: .warning,
check: { context in context.subject != .string("legacy") },
when: { context in context.path.components.last == .key("mode") },
),
)
let report = PureYAML.validationReports(
[
.init(name: "warnings.yaml", yaml: "mode: legacy"),
],
using: validator,
failOnWarnings: true,
)
#expect(report.failOnWarnings)
#expect(report.fileCount == 1)
#expect(report.validCount == 0)
#expect(report.invalidCount == 1)
#expect(!report.isValid)
#expect(report.sourceReports == [
.init(
name: "warnings.yaml",
report: .init([
.init(
kind: .validation,
severity: .warning,
file: "warnings.yaml",
documentIndex: 0,
path: .init([.key("mode")]),
reason: "Failed to satisfy: Mode is not legacy",
),
]),
invalid: true,
),
])
}
@Test("Batch reports render exact Markdown file contents")
func test_batchReportsRenderExactMarkdownFileContents() throws {
let report = makeMarkdownValidationReport()
let expected = expectedMarkdownValidationReport
#expect(report.markdownDescription(title: "YAML Production Validation") == expected)
#expect(try writeAndRead(report: report) == expected)
}
}
private func makeMarkdownValidationReport() -> PureYAML.Validation.BatchReport {
PureYAML.validationReports(
[
.init(name: "valid.yaml", yaml: "title: Ready"),
.init(name: "broken.yaml", yaml: "title: \"open"),
.init(
name: "duplicates.yaml",
yaml: """
title: First
title: Second
""",
),
],
)
}
private func writeAndRead(report: PureYAML.Validation.BatchReport) throws -> String {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
"PureYAMLValidationReport-\(UUID().uuidString)",
isDirectory: true,
)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: directory)
}
let file = directory.appendingPathComponent("validation-report.md")
try report.markdownDescription(title: "YAML Production Validation")
.write(to: file, atomically: true, encoding: .utf8)
return try String(contentsOf: file, encoding: .utf8)
}
private let expectedMarkdownValidationReport = """
# YAML Production Validation
- Files: 3
- Valid: 1
- Invalid: 2
- Diagnostics: 2
- Warnings fail: no
## valid.yaml
Status: valid
No diagnostics.
## broken.yaml
Status: invalid
```text
broken.yaml: error: parse: unterminated quoted string at line 1
```
## duplicates.yaml
Status: invalid
```text
duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'
```
"""

View File

@ -50,6 +50,7 @@ Those tests are the executable contract for the short-form examples.
| 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 |
| Produce non-throwing validation diagnostics | `PureYAML.validationReport(_:)` or `PureYAML.validationReports(_:)` | `PureYAML.Validation.Report` or `PureYAML.Validation.BatchReport` |
| Decode a typed value from YAML | `PureYAML.decode(_:from:)` | `Decodable` value |
| Encode a typed value to a value tree | `PureYAML.encode(_:)` | `PureYAML.Model.Value` |
| Encode a typed value to YAML | `PureYAML.encodeToYAML(_:options:)` | `String` |
@ -277,6 +278,26 @@ do {
The issue descriptions include `document[0]`, `document[1]`, and so on, while
the nested validation path still describes the location inside that document.
Use validation reports when a caller needs to process many faulty files without
throwing away the rest of the batch. Parse failures become diagnostics, ordinary
validation issues keep document indexes and paths, and warnings can optionally
mark a source invalid:
```swift
let report = PureYAML.validationReports(
[
.init(name: "valid.yaml", yaml: "title: Ready"),
.init(name: "broken.yaml", yaml: "title: \"open"),
],
failOnWarnings: true
)
let markdown = report.markdownDescription(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.
## Cross-Platform Verification
Before relying on a migration, run the same gates used by the repository: