feat(validation): add diagnostic report errors

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 21:06:42 +02:00
parent 13183a4353
commit 68883f2104
10 changed files with 1062 additions and 2 deletions

View File

@ -38,6 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add non-throwing single-source and batch validation reports with exact parse
and validation diagnostics, optional warning-as-failure status, and Markdown,
YAML, and JSON rendering for application-owned report files.
- Add diagnostic-first validation reports, a raw-source preflight scanner, and
`Validation.ReportError` so production tools can return structured JSON or
YAML error bodies for damaged YAML without claiming parsed artifacts exist.
### Changed

View File

@ -219,6 +219,23 @@ let yaml = report.yamlDescription(title: "YAML Validation")
let json = report.jsonDescription(title: "YAML Validation")
```
For production tools that need an error body for damaged YAML, use the
diagnostic-first APIs. They scan the raw source for common spacing and control
character problems before parsing, then include parser and model-validation
diagnostics in the same report. `parseValidated(_:)` and
`parseValidatedStream(_:)` throw `PureYAML.Validation.ReportError` when the
input cannot safely produce parsed artifacts:
```swift
do {
let document = try PureYAML.parseValidated(yaml, file: "openapi.yaml")
try PureYAML.validate(document)
} catch let error as PureYAML.Validation.ReportError {
let body = error.report.jsonDescription(title: "YAML Validation")
// Return body from the application-owned CLI or HTTP error response.
}
```
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

@ -0,0 +1,188 @@
public extension PureYAML {
/// Parses and validates YAML after collecting full-text diagnostics.
static func diagnosticValidationReport(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) -> Validation.Report {
diagnosticValidationResult(
yaml,
file: file,
using: validator,
preflightScanner: preflightScanner,
).report
}
/// Validates many YAML inputs with preflight diagnostics for damaged input.
static func diagnosticValidationReports(
_ sources: [Validation.Source],
using validator: Validation.Validator = .init(),
failOnWarnings: Bool = false,
preflightScanner: Validation.PreflightScanner = .init(),
) -> Validation.BatchReport {
let sourceReports = sources.map { source in
let report = diagnosticValidationReport(
source.yaml,
file: source.name,
using: validator,
preflightScanner: preflightScanner,
)
return Validation.SourceReport(
name: source.name,
report: report,
invalid: report.containsFailures(failOnWarnings: failOnWarnings),
)
}
return Validation.BatchReport(sourceReports: sourceReports, failOnWarnings: failOnWarnings)
}
/// Parses a YAML document and throws ``Validation/ReportError`` on failure.
static func parseValidated(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) throws -> Model.Value {
let documents = try parseValidatedStream(
yaml,
file: file,
using: validator,
preflightScanner: preflightScanner,
)
return documents.first?.value ?? .null
}
/// Parses a YAML stream and throws ``Validation/ReportError`` on failure.
static func parseValidatedStream(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) throws -> [Stream.Document] {
let result = diagnosticValidationResult(
yaml,
file: file,
using: validator,
preflightScanner: preflightScanner,
)
guard result.report.isValid, let documents = result.documents else {
throw Validation.ReportError(report: result.report)
}
return documents
}
}
private struct DiagnosticValidationResult {
var documents: [PureYAML.Stream.Document]?
var report: PureYAML.Validation.Report
}
private extension PureYAML {
static func diagnosticValidationResult(
_ yaml: String,
file: String?,
using validator: Validation.Validator,
preflightScanner: Validation.PreflightScanner,
) -> DiagnosticValidationResult {
var diagnostics = preflightScanner.diagnostics(in: yaml, file: file)
do {
let documents = try parseStream(yaml)
let result = validator.collect(documents)
diagnostics.append(contentsOf: 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 DiagnosticValidationResult(
documents: documents,
report: Validation.Report(diagnostics),
)
} catch let error as Parsing.ParseError {
diagnostics.append(Validation.Diagnostic(
kind: .parse,
severity: .error,
file: file,
line: error.line,
column: error.column,
reason: error.description,
))
return DiagnosticValidationResult(
documents: nil,
report: Validation.Report(diagnostics),
)
} catch {
diagnostics.append(Validation.Diagnostic(
kind: .parse,
severity: .error,
file: file,
reason: String(describing: error),
))
return DiagnosticValidationResult(
documents: nil,
report: Validation.Report(diagnostics),
)
}
}
}
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
}
}
}

View File

@ -0,0 +1,256 @@
public extension PureYAML.Validation {
/// Full-text diagnostic scanner that runs before YAML parsing.
struct PreflightScanner: Sendable {
public init() {}
public func diagnostics(
in yaml: String,
file: String? = nil,
) -> [Diagnostic] {
yaml.split(separator: "\n", omittingEmptySubsequences: false)
.enumerated()
.flatMap { lineIndex, line in
diagnostics(
in: String(line),
file: file,
line: lineIndex + 1,
)
}
}
public func diagnostics(
in line: String,
file: String? = nil,
line lineNumber: Int,
) -> [Diagnostic] {
var diagnostics: [Diagnostic] = []
if let column = firstIndentationTabColumn(in: line) {
diagnostics.append(.preflight(
file: file,
line: lineNumber,
column: column,
reason: "tab used for indentation; YAML indentation must use spaces",
))
}
if let column = missingMappingSpaceColumn(in: line) {
diagnostics.append(.preflight(
file: file,
line: lineNumber,
column: column,
reason: "missing space after ':' in mapping entry",
))
}
if let column = missingSequenceSpaceColumn(in: line) {
diagnostics.append(.preflight(
file: file,
line: lineNumber,
column: column,
reason: "missing space after '-' in sequence entry",
))
}
if let column = invalidControlCharacterColumn(in: line) {
diagnostics.append(.preflight(
file: file,
line: lineNumber,
column: column,
reason: "invalid control character in YAML source",
))
}
if let column = trailingWhitespaceColumn(in: line) {
diagnostics.append(.preflight(
file: file,
line: lineNumber,
column: column,
severity: .warning,
reason: "trailing whitespace",
))
}
return diagnostics
}
}
}
private extension PureYAML.Validation.Diagnostic {
static func preflight(
file: String?,
line: Int,
column: Int,
severity: PureYAML.Validation.Severity = .error,
reason: String,
) -> Self {
.init(
kind: .parse,
severity: severity,
file: file,
line: line,
column: column,
reason: reason,
)
}
}
private func firstIndentationTabColumn(in line: String) -> Int? {
for (offset, character) in line.enumerated() {
if character == "\t" {
return offset + 1
}
if character != " " {
return nil
}
}
return nil
}
private func missingMappingSpaceColumn(in line: String) -> Int? {
guard let colonIndex = line.firstIndex(of: ":") else {
return nil
}
guard isLikelyBlockMappingKey(before: colonIndex, in: line) else {
return nil
}
let afterIndex = line.index(after: colonIndex)
guard afterIndex < line.endIndex else {
return nil
}
let after = line[afterIndex]
if after == "/" {
let secondAfterIndex = line.index(after: afterIndex)
if secondAfterIndex < line.endIndex, line[secondAfterIndex] == "/" {
return nil
}
}
guard after != " ", after != "\t" else {
return nil
}
return line.distance(from: line.startIndex, to: afterIndex) + 1
}
private func isLikelyBlockMappingKey(
before colonIndex: String.Index,
in line: String,
) -> Bool {
let prefix = line[..<colonIndex]
guard let first = prefix.firstNonWhitespace else {
return false
}
guard first != "#", first != "{", first != "[", first != "\"", first != "'" else {
return false
}
let key = prefix.trimmedWhitespace
if key.hasPrefix("- ") {
return isLikelyPlainMappingKey(String(key.dropFirst(2)))
}
guard !key.hasPrefix("-") else {
return false
}
return isLikelyPlainMappingKey(key)
}
private func isLikelyPlainMappingKey(_ key: String) -> Bool {
guard !key.isEmpty, !key.containsSchemeSeparator else {
return false
}
return key.allSatisfy { character in
character.isLetter
|| character.isNumber
|| character == "_"
|| character == "-"
|| character == "."
|| character == "$"
}
}
private func missingSequenceSpaceColumn(in line: String) -> Int? {
let trimmed = line.trimmedLeadingWhitespace
guard trimmed.hasPrefix("-"),
!trimmed.hasPrefix("- "),
trimmed != "-",
!trimmed.hasPrefix("---")
else {
return nil
}
let afterDash = trimmed.dropFirst()
guard let firstAfterDash = afterDash.first,
!firstAfterDash.isNumber
else {
return nil
}
guard let dashIndex = line.firstIndex(of: "-") else {
return nil
}
return line.distance(from: line.startIndex, to: line.index(after: dashIndex)) + 1
}
private func invalidControlCharacterColumn(in line: String) -> Int? {
var column = 1
for scalar in line.unicodeScalars {
if scalar.value < 0x20, scalar.value != 0x09, scalar.value != 0x0D {
return column
}
column += 1
}
return nil
}
private func trailingWhitespaceColumn(in line: String) -> Int? {
guard line.last == " " || line.last == "\t" else {
return nil
}
var column = line.count
for character in line.reversed() {
if character == " " || character == "\t" {
column -= 1
} else {
break
}
}
return column + 1
}
private extension StringProtocol {
var firstNonWhitespace: Character? {
first { character in
character != " " && character != "\t"
}
}
var trimmedLeadingWhitespace: String {
String(drop(while: { character in
character == " " || character == "\t"
}))
}
var trimmedWhitespace: String {
let leadingTrimmed = drop(while: { character in
character == " " || character == "\t"
})
let reversedTrailingTrimmed = leadingTrimmed.reversed().drop(while: { character in
character == " " || character == "\t"
})
return String(reversedTrailingTrimmed.reversed())
}
}
private extension String {
var containsSchemeSeparator: Bool {
let scalars = Array(unicodeScalars)
guard scalars.count >= 3 else {
return false
}
for index in 0 ..< scalars.count - 2 {
let isSchemeSeparator = scalars[index] == ":"
&& scalars[index + 1] == "/"
&& scalars[index + 2] == "/"
if isSchemeSeparator {
return true
}
}
return false
}
}

View File

@ -18,6 +18,8 @@ public extension PureYAML.Validation {
public var kind: DiagnosticKind
public var severity: Severity
public var file: String?
public var line: Int?
public var column: Int?
public var documentIndex: Int?
public var path: Path?
public var reason: String
@ -26,6 +28,8 @@ public extension PureYAML.Validation {
kind: DiagnosticKind,
severity: Severity,
file: String? = nil,
line: Int? = nil,
column: Int? = nil,
documentIndex: Int? = nil,
path: Path? = nil,
reason: String,
@ -33,6 +37,8 @@ public extension PureYAML.Validation {
self.kind = kind
self.severity = severity
self.file = file
self.line = line
self.column = column
self.documentIndex = documentIndex
self.path = path
self.reason = reason
@ -43,6 +49,13 @@ public extension PureYAML.Validation {
if let file {
parts.append(file)
}
if let line, let column {
parts.append("line \(line), column \(column)")
} else if let line {
parts.append("line \(line)")
} else if let column {
parts.append("column \(column)")
}
if let documentIndex {
parts.append("document[\(documentIndex)]")
}

View File

@ -45,6 +45,37 @@ public extension PureYAML.Validation {
public func modelValue() -> PureYAML.Model.Value {
.sequence(diagnostics.map(\.modelValue))
}
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: "diagnostics", value: 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: "valid", value: .bool(isValid)),
.init(key: "diagnostics", value: .int(diagnostics.count)),
.init(key: "errors", value: .int(errors.count)),
.init(key: "warnings", value: .int(warnings.count)),
]))
}
}
/// Validation result for one named source in a batch.
@ -182,15 +213,24 @@ public extension PureYAML.Validation {
private extension PureYAML.Validation.Diagnostic {
var modelValue: PureYAML.Model.Value {
.mapping(.init([
var pairs: [PureYAML.Model.Pair] = [
.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),
]
if let line {
pairs.append(.init(key: "line", value: .int(line)))
}
if let column {
pairs.append(.init(key: "column", value: .int(column)))
}
pairs.append(contentsOf: [
.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)),
]))
])
return .mapping(.init(pairs))
}
}

View File

@ -0,0 +1,14 @@
public extension PureYAML.Validation {
/// Error that carries the structured validation report for failed input.
struct ReportError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var report: Report
public init(report: Report) {
self.report = report
}
public var description: String {
report.description
}
}
}

View File

@ -0,0 +1,168 @@
@testable import PureYAML
import Testing
@Suite("Diagnostic Validation Reports")
struct DiagnosticValidationReportTests {
@Test("Valid YAML returns parsed artifacts and an empty diagnostic report")
func test_validYAMLReturnsParsedArtifactsAndEmptyDiagnosticReport() throws {
let yaml = """
title: Ready
metadata:
owner: Tools
"""
let report = PureYAML.diagnosticValidationReport(yaml, file: "valid.yaml")
let value = try PureYAML.parseValidated(yaml, file: "valid.yaml")
#expect(report == PureYAML.Validation.Report())
#expect(value == .mapping(.init([
.init(key: "title", value: .string("Ready")),
.init(key: "metadata", value: .mapping(.init([
.init(key: "owner", value: .string("Tools")),
]))),
])))
}
@Test("Diagnostic reports combine preflight warnings with model validation errors")
func test_diagnosticReportsCombinePreflightWarningsWithModelValidationErrors() {
let report = PureYAML.diagnosticValidationReport(
[
"title: First",
"title: Second",
"note: ok ",
].joined(separator: "\n"),
file: "duplicates.yaml",
)
#expect(report.diagnostics == [
.init(
kind: .parse,
severity: .warning,
file: "duplicates.yaml",
line: 3,
column: 9,
reason: "trailing whitespace",
),
.init(
kind: .validation,
severity: .error,
file: "duplicates.yaml",
documentIndex: 0,
path: .init([.key("title")]),
reason: "Duplicate mapping key 'title'",
),
])
#expect(!report.isValid)
#expect(report.warnings.count == 1)
#expect(report.errors.count == 1)
}
@Test("Diagnostic reports keep scanning after lines that will make parsing fail")
func test_diagnosticReportsKeepScanningAfterLinesThatWillMakeParsingFail() {
let report = PureYAML.diagnosticValidationReport(
[
"title: \"open",
"summary:Missing",
"\tmetadata:",
].joined(separator: "\n"),
file: "broken.yaml",
)
#expect(report.diagnostics == [
.init(
kind: .parse,
severity: .error,
file: "broken.yaml",
line: 2,
column: 9,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "broken.yaml",
line: 3,
column: 1,
reason: "tab used for indentation; YAML indentation must use spaces",
),
.init(
kind: .parse,
severity: .error,
file: "broken.yaml",
line: 1,
reason: "unterminated quoted string at line 1",
),
])
#expect(report.description == [
"broken.yaml: line 2, column 9: error: parse: missing space after ':' in mapping entry",
"broken.yaml: line 3, column 1: error: parse: tab used for indentation; YAML indentation must use spaces",
"broken.yaml: line 1: error: parse: unterminated quoted string at line 1",
].joined(separator: "\n"))
}
@Test("Report errors carry machine-readable validation bodies")
func test_reportErrorsCarryMachineReadableValidationBodies() throws {
do {
_ = try PureYAML.parseValidated(
[
"title: \"open",
"summary:Missing",
"\tmetadata:",
].joined(separator: "\n"),
file: "broken.yaml",
)
recordIssue("expected validation report error")
} catch let error as PureYAML.Validation.ReportError {
let json = error.report.jsonDescription(title: "Production YAML Validation")
let yaml = error.report.yamlDescription(title: "Production YAML Validation")
#expect(error.report.diagnostics.count == 3)
#expect(json.contains(#""title": "Production YAML Validation""#))
#expect(json.contains(#""line": 2"#))
#expect(json.contains(#""column": 9"#))
#expect(json.contains(#""reason": "missing space after ':' in mapping entry""#))
let yamlRoot = try requireMapping(PureYAML.parse(yaml))
let diagnostics = yamlRoot?.sequence("diagnostics")
let firstDiagnostic = diagnostics?.first?.mapping
#expect(yamlRoot?["title"] == PureYAML.Model.Value.string("Production YAML Validation"))
#expect(yamlRoot?["summary"]?.mapping?["valid"] == PureYAML.Model.Value.bool(false))
#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?["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"))
} catch {
recordIssue("unexpected error \(error)")
}
}
@Test("Diagnostic batch reports can treat preflight warnings as failures")
func test_diagnosticBatchReportsCanTreatPreflightWarningsAsFailures() {
let report = PureYAML.diagnosticValidationReports(
[
.init(name: "valid.yaml", yaml: "title: Ready"),
.init(name: "warning.yaml", yaml: "title: Ready "),
],
failOnWarnings: true,
)
#expect(report.fileCount == 2)
#expect(report.validCount == 1)
#expect(report.invalidCount == 1)
#expect(report.diagnosticCount == 1)
#expect(report.sourceReports.map(\.invalid) == [false, true])
#expect(report.diagnostics == [
.init(
kind: .parse,
severity: .warning,
file: "warning.yaml",
line: 1,
column: 13,
reason: "trailing whitespace",
),
])
}
}

View File

@ -0,0 +1,323 @@
@testable import PureYAML
import Testing
@Suite("Preflight Scanner")
struct PreflightScannerTests {
@Test("Reports exact line diagnostics for common production spacing failures", arguments: preflightLineCases)
func test_reportsExactLineDiagnosticsForCommonProductionSpacingFailures(testCase: PreflightLineCase) {
let scanner = PureYAML.Validation.PreflightScanner()
#expect(scanner.diagnostics(
in: testCase.line,
file: "input.yaml",
line: testCase.lineNumber,
) == [testCase.expected])
}
@Test("Does not report false positives for valid scalar-like text")
func test_doesNotReportFalsePositivesForValidScalarLikeText() {
let scanner = PureYAML.Validation.PreflightScanner()
let yaml = [
"negative: -1",
"url: https://example.com",
"flow: {name:value}",
"- plain sequence item",
"---",
"# comment:without space",
].joined(separator: "\n")
#expect(scanner.diagnostics(in: yaml).isEmpty)
}
@Test("Scans the full damaged file instead of stopping at the first bad line")
func test_scansFullDamagedFileInsteadOfStoppingAtTheFirstBadLine() {
let scanner = PureYAML.Validation.PreflightScanner()
let yaml = productionLikeFaultyYAML
#expect(scanner.diagnostics(in: yaml, file: "production.yaml") == productionLikeFaultyDiagnostics)
}
}
struct PreflightLineCase: CustomStringConvertible {
var name: String
var line: String
var lineNumber: Int
var expected: PureYAML.Validation.Diagnostic
var description: String {
name
}
}
let preflightLineCases: [PreflightLineCase] = [
.init(
name: "tab indentation",
line: "\tname: value",
lineNumber: 7,
expected: .init(
kind: .parse,
severity: .error,
file: "input.yaml",
line: 7,
column: 1,
reason: "tab used for indentation; YAML indentation must use spaces",
),
),
.init(
name: "missing mapping space",
line: "summary:Missing space",
lineNumber: 8,
expected: .init(
kind: .parse,
severity: .error,
file: "input.yaml",
line: 8,
column: 9,
reason: "missing space after ':' in mapping entry",
),
),
.init(
name: "missing sequence space",
line: " -name: id",
lineNumber: 9,
expected: .init(
kind: .parse,
severity: .error,
file: "input.yaml",
line: 9,
column: 10,
reason: "missing space after '-' in sequence entry",
),
),
.init(
name: "trailing whitespace",
line: "description: OK ",
lineNumber: 10,
expected: .init(
kind: .parse,
severity: .warning,
file: "input.yaml",
line: 10,
column: 16,
reason: "trailing whitespace",
),
),
.init(
name: "invalid control character",
line: "control: \u{0007}",
lineNumber: 11,
expected: .init(
kind: .parse,
severity: .error,
file: "input.yaml",
line: 11,
column: 10,
reason: "invalid control character in YAML source",
),
),
]
let productionLikeFaultyYAML = [
"\tapiVersion: v1",
"kind:Pod",
"metadata:",
" name: app",
" labels:",
" app:demo",
"spec:",
" containers:",
" -name: app",
" - image:nginx",
" - ports:",
" -containerPort:80",
"env:",
" - name:MODE",
" - value:prod",
"description: ok ",
"control: \u{0007}",
"url: https://example.com",
"negative: -1",
"flow: {name:value}",
"replicas:3",
" -host: example.com",
" - port:8080",
" timeout:30",
" enabled:true",
" path:/health",
" query:?bad",
" item:[]",
" owner:me",
" tag:v1",
" mode:prod",
].joined(separator: "\n")
let productionLikeFaultyDiagnostics: [PureYAML.Validation.Diagnostic] = [
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 1,
column: 1,
reason: "tab used for indentation; YAML indentation must use spaces",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 2,
column: 6,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 6,
column: 9,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 9,
column: 6,
reason: "missing space after '-' in sequence entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 10,
column: 13,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 12,
column: 10,
reason: "missing space after '-' in sequence entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 14,
column: 10,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 15,
column: 11,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .warning,
file: "production.yaml",
line: 16,
column: 16,
reason: "trailing whitespace",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 17,
column: 10,
reason: "invalid control character in YAML source",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 21,
column: 10,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 22,
column: 4,
reason: "missing space after '-' in sequence entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 23,
column: 10,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 24,
column: 11,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 25,
column: 11,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 26,
column: 8,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 27,
column: 9,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 28,
column: 8,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 29,
column: 9,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 30,
column: 7,
reason: "missing space after ':' in mapping entry",
),
.init(
kind: .parse,
severity: .error,
file: "production.yaml",
line: 31,
column: 8,
reason: "missing space after ':' in mapping entry",
),
]

View File

@ -43,6 +43,7 @@ Those tests are the executable contract for the short-form examples.
|---|---|---|
| Parse YAML into an ordered value tree | `PureYAML.parse(_:)` | `PureYAML.Model.Value` |
| Parse a YAML stream into indexed documents | `PureYAML.parseStream(_:)` | `[PureYAML.Stream.Document]` |
| Parse and validate with a report-carrying failure | `PureYAML.parseValidated(_:)` or `PureYAML.parseValidatedStream(_:)` | Parsed artifacts or `PureYAML.Validation.ReportError` |
| Parse YAML while preserving explicit tags | `PureYAML.parseTagged(_:)` | `PureYAML.Tagged.Node` |
| 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 |
@ -51,6 +52,7 @@ Those tests are the executable contract for the short-form examples.
| 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` |
| Produce diagnostic-first reports for damaged YAML | `PureYAML.diagnosticValidationReport(_:)` or `PureYAML.diagnosticValidationReports(_:)` | Reports with preflight, parse, and validation diagnostics |
| 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` |
@ -301,6 +303,42 @@ PureYAML does not write files from the library target. Applications that need a
validation document should write `markdown`, `yaml`, or `json` through their
own file IO layer.
Use diagnostic-first reports when the source may be too damaged for the parser
to build a value tree. `PureYAML.Validation.PreflightScanner` scans the full raw
string first and reports common line-level failures such as tab indentation,
missing spaces after mapping or sequence indicators, trailing whitespace, and
invalid control characters. Parsing still runs afterward, but if parsing fails
the report contains the preflight diagnostics plus the parser diagnostic instead
of pretending a parsed artifact exists.
```swift
let report = PureYAML.diagnosticValidationReport(
"""
title: "open
summary:Missing
\tmetadata:
""",
file: "openapi.yaml",
)
let json = report.jsonDescription(title: "YAML Validation")
```
Production tools that prefer throwing control flow can use
`parseValidated(_:)` or `parseValidatedStream(_:)`. On failure, catch
`PureYAML.Validation.ReportError` and serialize its embedded report into the
tool-owned error body:
```swift
do {
let document = try PureYAML.parseValidated(yaml, file: "openapi.yaml")
// Continue with application-owned semantic validation.
} catch let error as PureYAML.Validation.ReportError {
let body = error.report.jsonDescription(title: "YAML Validation")
// Return body from the application-owned CLI or HTTP error response.
}
```
## Cross-Platform Verification
Before relying on a migration, run the same gates used by the repository: