From 68883f21044c5f8400659aa99f0ea2ef327e7119 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 5 Jun 2026 21:06:42 +0200 Subject: [PATCH] feat(validation): add diagnostic report errors --- CHANGELOG.md | 3 + README.md | 17 + Sources/Validation/DiagnosticValidation.swift | 188 ++++++++++ Sources/Validation/PreflightScanner.swift | 256 ++++++++++++++ .../Validation/Validation.Diagnostic.swift | 13 + Sources/Validation/Validation.Report.swift | 44 ++- .../Validation/Validation.ReportError.swift | 14 + Tests/DiagnosticValidationReportTests.swift | 168 +++++++++ Tests/PreflightScannerTests.swift | 323 ++++++++++++++++++ docs/USAGE.md | 38 +++ 10 files changed, 1062 insertions(+), 2 deletions(-) create mode 100644 Sources/Validation/DiagnosticValidation.swift create mode 100644 Sources/Validation/PreflightScanner.swift create mode 100644 Sources/Validation/Validation.ReportError.swift create mode 100644 Tests/DiagnosticValidationReportTests.swift create mode 100644 Tests/PreflightScannerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1549082..6048ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6f39f50..67a921c 100644 --- a/README.md +++ b/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 diff --git a/Sources/Validation/DiagnosticValidation.swift b/Sources/Validation/DiagnosticValidation.swift new file mode 100644 index 0000000..d5d30d3 --- /dev/null +++ b/Sources/Validation/DiagnosticValidation.swift @@ -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 + } + } +} diff --git a/Sources/Validation/PreflightScanner.swift b/Sources/Validation/PreflightScanner.swift new file mode 100644 index 0000000..12f01ca --- /dev/null +++ b/Sources/Validation/PreflightScanner.swift @@ -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[.. 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 + } +} diff --git a/Sources/Validation/Validation.Diagnostic.swift b/Sources/Validation/Validation.Diagnostic.swift index e4fba10..5782b0e 100644 --- a/Sources/Validation/Validation.Diagnostic.swift +++ b/Sources/Validation/Validation.Diagnostic.swift @@ -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)]") } diff --git a/Sources/Validation/Validation.Report.swift b/Sources/Validation/Validation.Report.swift index 2218f2d..f160e37 100644 --- a/Sources/Validation/Validation.Report.swift +++ b/Sources/Validation/Validation.Report.swift @@ -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)) } } diff --git a/Sources/Validation/Validation.ReportError.swift b/Sources/Validation/Validation.ReportError.swift new file mode 100644 index 0000000..286407a --- /dev/null +++ b/Sources/Validation/Validation.ReportError.swift @@ -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 + } + } +} diff --git a/Tests/DiagnosticValidationReportTests.swift b/Tests/DiagnosticValidationReportTests.swift new file mode 100644 index 0000000..5abb2bc --- /dev/null +++ b/Tests/DiagnosticValidationReportTests.swift @@ -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", + ), + ]) + } +} diff --git a/Tests/PreflightScannerTests.swift b/Tests/PreflightScannerTests.swift new file mode 100644 index 0000000..b2ee50d --- /dev/null +++ b/Tests/PreflightScannerTests.swift @@ -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", + ), +] diff --git a/docs/USAGE.md b/docs/USAGE.md index 39b73e8..9842ebe 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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: