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:
parent
4659ea33c8
commit
57c0c3b2da
52
Sources/Lint.swift
Normal file
52
Sources/Lint.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
@ -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
63
Tests/LintTests.swift
Normal 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") })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user