diff --git a/Sources/Lint.swift b/Sources/Lint.swift new file mode 100644 index 0000000..46854ec --- /dev/null +++ b/Sources/Lint.swift @@ -0,0 +1,52 @@ +public extension PureXML { + /// One editor-facing diagnostic: a message, a severity, and the source range + /// to highlight (nil when the location is unknown). The unified currency of + /// ``lint(_:validate:)``: parse problems and validation findings share it. + struct LintDiagnostic: Equatable, Sendable, CustomStringConvertible { + public var message: String + public var severity: Validation.Severity + public var range: Parsing.SourceRange? + + public init(message: String, severity: Validation.Severity, range: Parsing.SourceRange?) { + self.message = message + self.severity = severity + self.range = range + } + + public var description: String { + guard let range else { return "\(severity.rawValue): \(message)" } + return "\(severity.rawValue): \(message) at \(range)" + } + } + + /// Lints a possibly-invalid document the way an editor would: reads it once + /// into a ranged, best-effort tree (never throwing), then merges the parse + /// diagnostics and the validation findings into one source-ranged, + /// severity-tagged list in document order. + /// + /// `validate` runs over the recovered tree, so validation works even when the + /// document is not well-formed. Pass a schema's node-based validate (for + /// example `schema.validate`) to layer structural validation on top of + /// well-formedness; omit it to lint well-formedness alone. + static func lint( + _ xml: String, + validate: (Model.Node) -> [Validation.ValidationError] = { _ in [] }, + ) -> [LintDiagnostic] { + let (tree, parseDiagnostics) = readTree(xml) + var diagnostics = parseDiagnostics.map { diagnostic in + LintDiagnostic( + message: diagnostic.message, + severity: .error, + range: diagnostic.mark.map { Parsing.SourceRange(start: $0, end: $0) }, + ) + } + for error in validate(tree.node) { + diagnostics.append(LintDiagnostic( + message: error.reason, + severity: error.severity, + range: tree.sourceRange(at: error.codingPath), + )) + } + return diagnostics.sorted { ($0.range?.start.offset ?? .max) < ($1.range?.start.offset ?? .max) } + } +} diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index 7efdb25..dfc3e85 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -37,9 +37,13 @@ public extension PureXML.Schema { /// ``PureXML/Validation/ValidationError`` per violation. Reports an error /// when the root element has no global declaration. public func validate(_ xml: String) throws -> [PureXML.Validation.ValidationError] { - guard case let .document(children) = try PureXML.parse(xml), - let root = children.compactMap(\.element).first - else { + try validate(PureXML.parse(xml)) + } + + /// Validates an already-parsed node tree, so a caller can validate the same + /// (possibly recovered) tree it is editing. See ``validate(_:)-(String)``. + public func validate(_ node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] { + guard case let .document(children) = node, let root = children.compactMap(\.element).first else { return [.init(reason: "the document has no root element", at: [])] } guard let declaration = elements[root.name.localName] else { diff --git a/Tests/LintTests.swift b/Tests/LintTests.swift new file mode 100644 index 0000000..f919136 --- /dev/null +++ b/Tests/LintTests.swift @@ -0,0 +1,63 @@ +@testable import PureXML +import Testing + +/// The unified lint surface: one source-ranged, severity-tagged list merging +/// parse recovery and schema validation, the way an editor consumes it. +@Suite("Lint") +struct LintTests { + @Test("A well-formed, valid document lints clean") + func test_clean() throws { + let xsd = """ + + + + """ + let schema = try PureXML.Schema.Document(xsd) + #expect(PureXML.lint("3", validate: schema.validate).isEmpty) + } + + @Test("Well-formedness alone is linted without a schema") + func test_parseOnly() { + let diagnostics = PureXML.lint("x") + #expect(diagnostics.count == 1) + #expect(diagnostics[0].message.contains("unexpected end of input")) + #expect(diagnostics[0].range != nil) + } + + @Test("A validation finding is located by source range") + func test_validationRanged() throws { + let xsd = """ + + + + + + + + """ + let schema = try PureXML.Schema.Document(xsd) + let diagnostics = PureXML.lint("\n lots\n", validate: schema.validate) + #expect(diagnostics.count == 1) + #expect(diagnostics[0].severity == .error) + #expect(diagnostics[0].range?.start == PureXML.Parsing.Mark(line: 2, column: 3, offset: 10)) + } + + @Test("Validation runs over the recovered tree, so invalid input still lints") + func test_invalidStillValidates() throws { + // The document is both not well-formed (truncated) and schema-invalid. + let xsd = """ + + + + + + + + """ + let schema = try PureXML.Schema.Document(xsd) + let diagnostics = PureXML.lint("lots", validate: schema.validate) + // One parse diagnostic (truncation) and one validation finding (qty not an integer). + #expect(diagnostics.contains { $0.message.contains("unexpected end of input") }) + #expect(diagnostics.contains { $0.message.contains("not a valid") }) + } +}