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") })
+ }
+}