feat(validation): add diagnostic report errors
This commit is contained in:
parent
13183a4353
commit
68883f2104
@ -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
|
||||
|
||||
|
||||
17
README.md
17
README.md
@ -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
|
||||
|
||||
188
Sources/Validation/DiagnosticValidation.swift
Normal file
188
Sources/Validation/DiagnosticValidation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
256
Sources/Validation/PreflightScanner.swift
Normal file
256
Sources/Validation/PreflightScanner.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)]")
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
Sources/Validation/Validation.ReportError.swift
Normal file
14
Sources/Validation/Validation.ReportError.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Tests/DiagnosticValidationReportTests.swift
Normal file
168
Tests/DiagnosticValidationReportTests.swift
Normal 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",
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
323
Tests/PreflightScannerTests.swift
Normal file
323
Tests/PreflightScannerTests.swift
Normal 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",
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user