diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c79ce5..3572ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 examples. - Add deliberately failing YAML fixtures that demonstrate exact validation diagnostics, strict failures, and non-strict warning returns. +- Add non-throwing single-source and batch validation reports with exact parse + and validation diagnostics, optional warning-as-failure status, and Markdown + rendering for application-owned report files. ### Changed @@ -50,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tagged parser and validator explicitly. - Preserve anchors and tags on mapping values and sequence items whose actual node starts on the following indented line. +- Harden real-world parsing for empty documents, indented root fragments, + JSON-style flow mapping pairs, and tab-indented files encountered in corpus + validation. ## [0.1.0] - 2026-06-05 diff --git a/README.md b/README.md index 994f750..7d66860 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,19 @@ errors still throw. let issues = try PureYAML.validate(document, strict: false) ``` +For production-style validation over many YAML inputs, use the non-throwing +report API. It validates every source, turns parse failures into diagnostics, +and can render Markdown that a consuming tool writes to disk: + +```swift +let report = PureYAML.validationReports([ + .init(name: "valid.yaml", yaml: "title: Ready"), + .init(name: "broken.yaml", yaml: "title: \"open"), +]) + +let markdown = report.markdownDescription(title: "YAML Validation") +``` + Custom validation rules can be layered onto the default validator or attached to a blank validator when callers want only project-specific checks. Validation tests pin exact issue paths, descriptions, severity handling, rule traversal diff --git a/Sources/Parsing/Scanner.Indentation.swift b/Sources/Parsing/Scanner.Indentation.swift index 4bba4d0..58f4733 100644 --- a/Sources/Parsing/Scanner.Indentation.swift +++ b/Sources/Parsing/Scanner.Indentation.swift @@ -37,7 +37,8 @@ extension PureYAML.Parsing.Scanner { width += 1 state.reader.advance() } else if character == "\t" { - throw PureYAML.Parsing.ParseError.tabIndentation(line: state.reader.mark.line) + width = nextTabStop(after: width) + state.reader.advance() } else { return width } @@ -45,6 +46,10 @@ extension PureYAML.Parsing.Scanner { return width } + func nextTabStop(after width: Int) -> Int { + ((width / 4) + 1) * 4 + } + func shouldProcessIndentation(_ state: State) -> Bool { guard !state.reader.isAtEnd, !isLineBreak(state.reader.peek()) else { return false diff --git a/Sources/Parsing/TokenEventParser.Flow.swift b/Sources/Parsing/TokenEventParser.Flow.swift index c384f4b..c90c3bb 100644 --- a/Sources/Parsing/TokenEventParser.Flow.swift +++ b/Sources/Parsing/TokenEventParser.Flow.swift @@ -73,11 +73,33 @@ extension PureYAML.Parsing.TokenEventParser { _ = cursor.advance() continue } - endMark = try parseMappingPair().endMark + endMark = try parseFlowMappingPair().endMark if cursor.current?.kind.isFlowTerminator == false { throw unexpectedToken(expected: "flow entry or flow mapping end") } } throw unexpectedToken(expected: "flow mapping end") } + + mutating func parseFlowMappingPair() throws -> PureYAML.Parsing.NodeResult { + if cursor.current?.kind.isMappingKey == true { + return try parseMappingPair() + } + + _ = try parseNode() + let valueIndicator = try expect("mapping value") { kind in + if case .mappingValue = kind { + return true + } + return false + } + + let properties = consumeProperties() + guard properties != .none || cursor.current?.kind.isFlowTerminator == false else { + let mark = valueIndicator.endMark + events.append(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark) + } + return try parseNode(properties: properties) + } } diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index 304d7e9..c9c7607 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -27,8 +27,11 @@ extension PureYAML.Parsing { try parseDocument() didParseDocument = true } - guard didParseDocument else { - throw ParseError.emptyDocument + if !didParseDocument { + let mark = cursor.current?.mark ?? streamStart.endMark + events.append(.documentStart(mark: mark)) + appendEmptyDocument(at: mark) + events.append(.documentEnd(mark: mark)) } let streamEnd = try expect("stream end") { kind in if case .streamEnd = kind { @@ -60,8 +63,7 @@ extension PureYAML.Parsing.TokenEventParser { return } - let result = try parseNode() - var endMark = result.endMark + var endMark = try parseDocumentRootNode().endMark while cursor.current?.kind.isDedent == true { let dedent = try expect("dedent") { $0.isDedent } endMark = dedent.endMark @@ -81,6 +83,18 @@ extension PureYAML.Parsing.TokenEventParser { events.append(.documentEnd(mark: endMark)) } + mutating func parseDocumentRootNode() throws -> PureYAML.Parsing.NodeResult { + guard cursor.current?.kind.isIndent == true else { + return try parseNode() + } + _ = try expect("indent") { $0.isIndent } + let result = try parseNode() + if cursor.current?.kind.isDedent == true { + _ = try expect("dedent") { $0.isDedent } + } + return result + } + mutating func parseDocumentStartMark() -> PureYAML.Parsing.Mark { guard cursor.current?.kind.isDocumentStart == true else { return cursor.current?.mark ?? cursor.previous?.endMark ?? .start diff --git a/Sources/Validation/Validation.Diagnostic.swift b/Sources/Validation/Validation.Diagnostic.swift new file mode 100644 index 0000000..e4fba10 --- /dev/null +++ b/Sources/Validation/Validation.Diagnostic.swift @@ -0,0 +1,56 @@ +public extension PureYAML.Validation { + enum DiagnosticKind: Equatable, Sendable, CustomStringConvertible { + case parse + case validation + + public var description: String { + switch self { + case .parse: + "parse" + case .validation: + "validation" + } + } + } + + /// A parse or validation diagnostic suitable for batch validation reports. + struct Diagnostic: Equatable, Sendable, CustomStringConvertible { + public var kind: DiagnosticKind + public var severity: Severity + public var file: String? + public var documentIndex: Int? + public var path: Path? + public var reason: String + + public init( + kind: DiagnosticKind, + severity: Severity, + file: String? = nil, + documentIndex: Int? = nil, + path: Path? = nil, + reason: String, + ) { + self.kind = kind + self.severity = severity + self.file = file + self.documentIndex = documentIndex + self.path = path + self.reason = reason + } + + public var description: String { + var parts: [String] = [] + if let file { + parts.append(file) + } + if let documentIndex { + parts.append("document[\(documentIndex)]") + } + if let path { + parts.append(path.isRoot ? "root" : path.description) + } + let location = parts.isEmpty ? "input" : parts.joined(separator: ": ") + return "\(location): \(severity): \(kind): \(reason)" + } + } +} diff --git a/Sources/Validation/Validation.Report.swift b/Sources/Validation/Validation.Report.swift new file mode 100644 index 0000000..f795926 --- /dev/null +++ b/Sources/Validation/Validation.Report.swift @@ -0,0 +1,187 @@ +public extension PureYAML.Validation { + /// Named YAML source used for batch validation without coupling PureYAML to file IO. + struct Source: Equatable, Sendable { + public var name: String + public var yaml: String + + public init(name: String, yaml: String) { + self.name = name + self.yaml = yaml + } + } + + /// Non-throwing validation report for one YAML input. + struct Report: Equatable, Sendable, CustomStringConvertible { + public var diagnostics: [Diagnostic] + + public init(_ diagnostics: [Diagnostic] = []) { + self.diagnostics = diagnostics + } + + public var isEmpty: Bool { + diagnostics.isEmpty + } + + public var errors: [Diagnostic] { + diagnostics.filter { $0.severity == .error } + } + + public var warnings: [Diagnostic] { + diagnostics.filter { $0.severity == .warning } + } + + public var isValid: Bool { + errors.isEmpty + } + + public func containsFailures(failOnWarnings: Bool = false) -> Bool { + failOnWarnings ? !diagnostics.isEmpty : !errors.isEmpty + } + + public var description: String { + diagnostics.map(\.description).joined(separator: "\n") + } + } + + /// Validation result for one named source in a batch. + struct SourceReport: Equatable, Sendable { + public var name: String + public var report: Report + public var invalid: Bool + + public init(name: String, report: Report, invalid: Bool) { + self.name = name + self.report = report + self.invalid = invalid + } + } + + /// Non-throwing aggregate report for validating many YAML inputs. + struct BatchReport: Equatable, Sendable, CustomStringConvertible { + public var sourceReports: [SourceReport] + public var failOnWarnings: Bool + + public init( + sourceReports: [SourceReport], + failOnWarnings: Bool = false, + ) { + self.sourceReports = sourceReports + self.failOnWarnings = failOnWarnings + } + + public var fileCount: Int { + sourceReports.count + } + + public var validCount: Int { + sourceReports.count(where: { !$0.invalid }) + } + + public var invalidCount: Int { + sourceReports.count(where: { $0.invalid }) + } + + public var diagnosticCount: Int { + sourceReports.reduce(0) { count, sourceReport in + count + sourceReport.report.diagnostics.count + } + } + + public var isValid: Bool { + invalidCount == 0 + } + + public var diagnostics: [Diagnostic] { + sourceReports.flatMap(\.report.diagnostics) + } + + public var description: String { + diagnostics.map(\.description).joined(separator: "\n") + } + + public func markdownDescription( + title: String = "PureYAML Validation Report", + ) -> String { + var lines: [String] = [ + "# \(title)", + "", + "- Files: \(fileCount)", + "- Valid: \(validCount)", + "- Invalid: \(invalidCount)", + "- Diagnostics: \(diagnosticCount)", + "- Warnings fail: \(failOnWarnings ? "yes" : "no")", + "", + ] + + for sourceReport in sourceReports { + lines.append("## \(sourceReport.name)") + lines.append("") + lines.append("Status: \(sourceReport.invalid ? "invalid" : "valid")") + lines.append("") + + if sourceReport.report.isEmpty { + lines.append("No diagnostics.") + } else { + lines.append("```text") + lines.append(sourceReport.report.description) + lines.append("```") + } + + lines.append("") + } + + return lines.joined(separator: "\n") + } + } +} + +public extension PureYAML { + /// Parses and validates YAML without throwing, returning parse and validation diagnostics. + static func validationReport( + _ yaml: String, + file: String? = nil, + using validator: Validation.Validator = .init(), + ) -> Validation.Report { + do { + let documents = try parseStream(yaml) + let result = validator.collect(documents) + let diagnostics = result.issues.map { streamIssue in + Validation.Diagnostic( + kind: .validation, + severity: streamIssue.issue.severity, + file: file, + documentIndex: streamIssue.documentIndex, + path: streamIssue.issue.path, + reason: streamIssue.issue.reason, + ) + } + return Validation.Report(diagnostics) + } catch { + return Validation.Report([ + Validation.Diagnostic( + kind: .parse, + severity: .error, + file: file, + reason: String(describing: error), + ), + ]) + } + } + + /// Validates many YAML inputs without throwing or stopping at the first failure. + static func validationReports( + _ sources: [Validation.Source], + using validator: Validation.Validator = .init(), + failOnWarnings: Bool = false, + ) -> Validation.BatchReport { + let reports = sources.map { source in + let report = validationReport(source.yaml, file: source.name, using: validator) + return Validation.SourceReport( + name: source.name, + report: report, + invalid: report.containsFailures(failOnWarnings: failOnWarnings), + ) + } + return Validation.BatchReport(sourceReports: reports, failOnWarnings: failOnWarnings) + } +} diff --git a/Tests/ParsingErrorTests.swift b/Tests/ParsingErrorTests.swift index 1b6aa92..74eeeed 100644 --- a/Tests/ParsingErrorTests.swift +++ b/Tests/ParsingErrorTests.swift @@ -5,9 +5,6 @@ import Testing struct ParsingErrorTests { @Test("Reports exact parser errors") func test_errorReporting() { - expectParseError("", .emptyDocument) - expectParseError("# comment only", .emptyDocument) - expectParseError("\tkey: value", .tabIndentation(line: 1)) expectParseError("root:\n child: yes\n wrong: no", .unexpectedIndentation(line: 3)) expectParseError("- one\nkey: value", .mixedCollectionStyles(line: 2)) expectParseError("root:\n child: value\n dangling", .unexpectedToken( @@ -28,6 +25,25 @@ struct ParsingErrorTests { )) } + @Test("Parses empty and comment-only documents as null") + func test_emptyAndCommentOnlyDocumentsParseAsNull() throws { + #expect(try PureYAML.parse("") == .null) + #expect(try PureYAML.parse("# comment only") == .null) + #expect(try PureYAML.parse(" \n# comment only\n") == .null) + } + + @Test("Parses tab indentation with permissive tab stops") + func test_tabIndentationUsesPermissiveTabStops() throws { + let root = try requireMapping(PureYAML.parse( + """ + root: + \tchild: value + """, + )) + + #expect(root?.mapping("root")?["child"] == .string("value")) + } + @Test("Parser errors describe returned failures") func test_errorDescriptions() { #expect(PureYAML.Parsing.ParseError.emptyDocument.description == "document is empty") diff --git a/Tests/ParsingEventTests.swift b/Tests/ParsingEventTests.swift index cf5dd09..d0fc499 100644 --- a/Tests/ParsingEventTests.swift +++ b/Tests/ParsingEventTests.swift @@ -105,6 +105,32 @@ struct ParsingEventTests { #expect(!descriptions.contains { $0.contains("it''s") }) } + @Test("Parses JSON-style flow mapping pairs") + func test_jsonStyleFlowMappingPairs() throws { + let root = try requireMapping(PureYAML.parse( + """ + value: + { + "version" : "7", + "fields" : [ + { + "key" : "productType", + "required" : true + } + ] + } + """, + )) + + let value = root?.mapping("value") + let field = value?.sequence("fields")?.first?.mapping + + #expect(value?["version"] == .string("7")) + #expect(field?["key"] == .string("productType")) + #expect(field?["required"] == .bool(true)) + #expect(value?["missing"] == nil) + } + @Test("Emits golden events for block scalar headers") func test_blockScalarHeaders() throws { let events = try PureYAML.Parsing.Parser().parseEvents( @@ -141,9 +167,6 @@ struct ParsingEventTests { @Test("Reports parse-event errors") func test_errorReporting() { - expectError(PureYAML.Parsing.ParseError.emptyDocument) { - try PureYAML.Parsing.Parser().parseEvents("") - } expectError(PureYAML.Parsing.ParseError.unexpectedIndentation(line: 3)) { try PureYAML.Parsing.Parser().parseEvents("root:\n child: yes\n wrong: no") } @@ -166,6 +189,19 @@ struct ParsingEventTests { description: "expected flow entry or flow sequence end at line 1, column 17, found streamEnd", ) } + + @Test("Emits null-document events for empty input") + func test_emptyInputEmitsNullDocumentEvents() throws { + let events = try PureYAML.Parsing.Parser().parseEvents("") + + #expect(events.map(\.description) == [ + "streamStart @1:1@0", + "documentStart @1:1@0", + "scalar value=\"\" anchor=- tag=- style=plain @1:1@0", + "documentEnd @1:1@0", + "streamEnd @1:1@0", + ]) + } } private func expectParseEventError( diff --git a/Tests/ParsingIndentationRegressionTests.swift b/Tests/ParsingIndentationRegressionTests.swift index 5a36e65..8bf0737 100644 --- a/Tests/ParsingIndentationRegressionTests.swift +++ b/Tests/ParsingIndentationRegressionTests.swift @@ -3,6 +3,38 @@ import Testing @Suite("Parsing Indentation Regressions") struct ParsingIndentationRegressionTests { + @Test("Parses indented root mapping fragments") + func test_indentedRootMappingFragments() throws { + let root = try requireMapping(PureYAML.parse( + """ + title: OpenAPI Generation Demo + version: 1.0.0 + contact: + name: API Support + email: support@example.com + """, + )) + + #expect(root?["title"] == .string("OpenAPI Generation Demo")) + #expect(root?["version"] == .string("1.0.0")) + #expect(root?.mapping("contact")?["name"] == .string("API Support")) + #expect(root?.mapping("contact")?["email"] == .string("support@example.com")) + #expect(root?["missing"] == nil) + } + + @Test("Parses indented root mapping fragments with leading blank lines") + func test_indentedRootMappingFragmentsWithLeadingBlankLines() throws { + let root = try requireMapping(PureYAML.parse( + """ + + securitySchemes: + """, + )) + + #expect(root?["securitySchemes"] == .null) + #expect(root?["missing"] == nil) + } + @Test("Parses compact sequence item mappings without swallowing lower-column siblings") func test_compactSequenceItemMappingsDoNotSwallowLowerColumnSiblings() throws { let root = try requireMapping(PureYAML.parse( diff --git a/Tests/ScannerTests.swift b/Tests/ScannerTests.swift index e2ba054..1a96a8b 100644 --- a/Tests/ScannerTests.swift +++ b/Tests/ScannerTests.swift @@ -238,7 +238,6 @@ struct ScannerTests { @Test("Reports scanner errors") func test_errorReporting() { - expectScannerError("\tname: value", .tabIndentation(line: 1)) expectScannerError("root:\n child: yes\n wrong: no", .unexpectedIndentation(line: 3)) expectScannerError("name: \"open", .unterminatedQuotedString(line: 1)) expectScannerError("name: 'open", .unterminatedQuotedString(line: 1)) diff --git a/Tests/ValidationReportTests.swift b/Tests/ValidationReportTests.swift new file mode 100644 index 0000000..becbb43 --- /dev/null +++ b/Tests/ValidationReportTests.swift @@ -0,0 +1,245 @@ +import Foundation +@testable import PureYAML +import Testing + +@Suite("Validation Reports") +struct ValidationReportTests { + @Test("Valid YAML returns an empty report") + func test_validYAMLReturnsEmptyReport() { + let report = PureYAML.validationReport("title: Ready", file: "valid.yaml") + + #expect(report.isEmpty) + #expect(report.isValid) + #expect(report.description == "") + } + + @Test("Parse failures return file-scoped diagnostics") + func test_parseFailuresReturnFileScopedDiagnostics() { + let report = PureYAML.validationReport("title: \"open", file: "broken.yaml") + + #expect(report.diagnostics == [ + .init( + kind: .parse, + severity: .error, + file: "broken.yaml", + reason: "unterminated quoted string at line 1", + ), + ]) + #expect(!report.isValid) + #expect(report.description == "broken.yaml: error: parse: unterminated quoted string at line 1") + } + + @Test("Duplicate keys return document and path diagnostics") + func test_duplicateKeysReturnDocumentAndPathDiagnostics() { + let report = PureYAML.validationReport( + """ + title: First + title: Second + """, + file: "duplicates.yaml", + ) + + #expect(report.diagnostics == [ + .init( + kind: .validation, + severity: .error, + file: "duplicates.yaml", + documentIndex: 0, + path: .init([.key("title")]), + reason: "Duplicate mapping key 'title'", + ), + ]) + #expect(report.description == "duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'") + } + + @Test("Warning reports stay visible without making the report invalid") + func test_warningReportsStayVisibleWithoutMakingReportInvalid() { + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Rule( + description: "Mode is not legacy", + severity: .warning, + check: { context in context.subject != .string("legacy") }, + when: { context in context.path.components.last == .key("mode") }, + ), + ) + + let report = PureYAML.validationReport("mode: legacy", file: "warnings.yaml", using: validator) + + #expect(report.diagnostics == [ + .init( + kind: .validation, + severity: .warning, + file: "warnings.yaml", + documentIndex: 0, + path: .init([.key("mode")]), + reason: "Failed to satisfy: Mode is not legacy", + ), + ]) + #expect(report.isValid) + #expect(report.warnings.count == 1) + #expect(report.errors.isEmpty) + } + + @Test("Batch reports collect every source without stopping at the first failure") + func test_batchReportsCollectEverySourceWithoutStoppingAtFirstFailure() { + let validator = PureYAML.Validation.Validator().validating( + PureYAML.Validation.Rule( + description: "Mode is not legacy", + severity: .warning, + check: { context in context.subject != .string("legacy") }, + when: { context in context.path.components.last == .key("mode") }, + ), + ) + + let report = PureYAML.validationReports( + [ + .init(name: "valid.yaml", yaml: "title: Ready"), + .init(name: "broken.yaml", yaml: "title: \"open"), + .init( + name: "duplicates.yaml", + yaml: """ + title: First + title: Second + """, + ), + .init(name: "warnings.yaml", yaml: "mode: legacy"), + ], + using: validator, + ) + + #expect(report.fileCount == 4) + #expect(report.validCount == 2) + #expect(report.invalidCount == 2) + #expect(report.diagnosticCount == 3) + #expect(!report.isValid) + #expect(report.description == [ + "broken.yaml: error: parse: unterminated quoted string at line 1", + "duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'", + "warnings.yaml: document[0]: $.mode: warning: validation: Failed to satisfy: Mode is not legacy", + ].joined(separator: "\n")) + #expect(report.sourceReports.map(\.name) == [ + "valid.yaml", + "broken.yaml", + "duplicates.yaml", + "warnings.yaml", + ]) + #expect(report.sourceReports.map(\.invalid) == [false, true, true, false]) + } + + @Test("Batch reports can treat warnings as failures") + func test_batchReportsCanTreatWarningsAsFailures() { + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Rule( + description: "Mode is not legacy", + severity: .warning, + check: { context in context.subject != .string("legacy") }, + when: { context in context.path.components.last == .key("mode") }, + ), + ) + + let report = PureYAML.validationReports( + [ + .init(name: "warnings.yaml", yaml: "mode: legacy"), + ], + using: validator, + failOnWarnings: true, + ) + + #expect(report.failOnWarnings) + #expect(report.fileCount == 1) + #expect(report.validCount == 0) + #expect(report.invalidCount == 1) + #expect(!report.isValid) + #expect(report.sourceReports == [ + .init( + name: "warnings.yaml", + report: .init([ + .init( + kind: .validation, + severity: .warning, + file: "warnings.yaml", + documentIndex: 0, + path: .init([.key("mode")]), + reason: "Failed to satisfy: Mode is not legacy", + ), + ]), + invalid: true, + ), + ]) + } + + @Test("Batch reports render exact Markdown file contents") + func test_batchReportsRenderExactMarkdownFileContents() throws { + let report = makeMarkdownValidationReport() + let expected = expectedMarkdownValidationReport + + #expect(report.markdownDescription(title: "YAML Production Validation") == expected) + + #expect(try writeAndRead(report: report) == expected) + } +} + +private func makeMarkdownValidationReport() -> PureYAML.Validation.BatchReport { + PureYAML.validationReports( + [ + .init(name: "valid.yaml", yaml: "title: Ready"), + .init(name: "broken.yaml", yaml: "title: \"open"), + .init( + name: "duplicates.yaml", + yaml: """ + title: First + title: Second + """, + ), + ], + ) +} + +private func writeAndRead(report: PureYAML.Validation.BatchReport) throws -> String { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "PureYAMLValidationReport-\(UUID().uuidString)", + isDirectory: true, + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + let file = directory.appendingPathComponent("validation-report.md") + try report.markdownDescription(title: "YAML Production Validation") + .write(to: file, atomically: true, encoding: .utf8) + return try String(contentsOf: file, encoding: .utf8) +} + +private let expectedMarkdownValidationReport = """ +# YAML Production Validation + +- Files: 3 +- Valid: 1 +- Invalid: 2 +- Diagnostics: 2 +- Warnings fail: no + +## valid.yaml + +Status: valid + +No diagnostics. + +## broken.yaml + +Status: invalid + +```text +broken.yaml: error: parse: unterminated quoted string at line 1 +``` + +## duplicates.yaml + +Status: invalid + +```text +duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title' +``` + +""" diff --git a/docs/USAGE.md b/docs/USAGE.md index 54d7f1b..32361c2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -50,6 +50,7 @@ Those tests are the executable contract for the short-form examples. | Emit YAML from stream documents | `PureYAML.dump(_:options:)` with `[PureYAML.Stream.Document]` | `String` | | Validate a value tree | `PureYAML.validate(_:using:strict:)` | `[PureYAML.Validation.Issue]` or a thrown issue collection | | Validate stream documents | `PureYAML.validate(_:using:strict:)` with `[PureYAML.Stream.Document]` | `[PureYAML.Stream.Issue]` or a thrown stream issue collection | +| Produce non-throwing validation diagnostics | `PureYAML.validationReport(_:)` or `PureYAML.validationReports(_:)` | `PureYAML.Validation.Report` or `PureYAML.Validation.BatchReport` | | Decode a typed value from YAML | `PureYAML.decode(_:from:)` | `Decodable` value | | Encode a typed value to a value tree | `PureYAML.encode(_:)` | `PureYAML.Model.Value` | | Encode a typed value to YAML | `PureYAML.encodeToYAML(_:options:)` | `String` | @@ -277,6 +278,26 @@ do { The issue descriptions include `document[0]`, `document[1]`, and so on, while the nested validation path still describes the location inside that document. +Use validation reports when a caller needs to process many faulty files without +throwing away the rest of the batch. Parse failures become diagnostics, ordinary +validation issues keep document indexes and paths, and warnings can optionally +mark a source invalid: + +```swift +let report = PureYAML.validationReports( + [ + .init(name: "valid.yaml", yaml: "title: Ready"), + .init(name: "broken.yaml", yaml: "title: \"open"), + ], + failOnWarnings: true +) + +let markdown = report.markdownDescription(title: "YAML Validation") +``` + +PureYAML does not write files from the library target. Applications that need a +validation document should write `markdown` through their own file IO layer. + ## Cross-Platform Verification Before relying on a migration, run the same gates used by the repository: