feat(editor): unified lint surface merging parse and validation diagnostics

Slice 2 of #94. One call for an editor: PureXML.lint reads the document once into
the ranged, best-effort tree (never throwing), then merges the parse diagnostics
and the validation findings into a single source-ranged, severity-tagged list in
document order.

- LintDiagnostic carries message, severity, and the SourceRange to highlight.
- lint(_:validate:) takes a node-based validate closure that runs over the
  RECOVERED tree, so validation works even when the document is not well-formed:
  a truncated, schema-invalid document yields both a parse diagnostic and the
  validation finding, each with a range.
- Schema.Document gains validate(_ node:) so a caller validates the same tree it
  is editing; validate(_ xml:) delegates to it.

Tests cover a clean lint, well-formedness-only lint, a range-located validation
finding, and the both-truncated-and-invalid editor case.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 13:18:29 +02:00
parent 4659ea33c8
commit 57c0c3b2da
3 changed files with 122 additions and 3 deletions

52
Sources/Lint.swift Normal file
View File

@ -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) }
}
}

View File

@ -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 {

63
Tests/LintTests.swift Normal file
View File

@ -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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="n" type="xs:integer"/>
</xs:schema>
"""
let schema = try PureXML.Schema.Document(xsd)
#expect(PureXML.lint("<n>3</n>", validate: schema.validate).isEmpty)
}
@Test("Well-formedness alone is linted without a schema")
func test_parseOnly() {
let diagnostics = PureXML.lint("<a><b>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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="order">
<xs:complexType>
<xs:sequence><xs:element name="qty" type="xs:integer"/></xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
"""
let schema = try PureXML.Schema.Document(xsd)
let diagnostics = PureXML.lint("<order>\n <qty>lots</qty>\n</order>", 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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="order">
<xs:complexType>
<xs:sequence><xs:element name="qty" type="xs:integer"/></xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
"""
let schema = try PureXML.Schema.Document(xsd)
let diagnostics = PureXML.lint("<order><qty>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") })
}
}