feat(editor): source spans on the tree, mapping findings to positions

Slice 1 of #94 (editor integration). The keystone: a located validation finding
can now be mapped back to a source range.

- TreeNode gains a sourceRange (start/end Mark); SourceRange is a half-open span.
- PureXML.readTree builds a mutable, parent-aware, best-effort tree whose every
  node carries its span (an element from the < of its start tag to past its end
  tag; leaves over the text they consumed). Never throws, recovers in place,
  returns located diagnostics.
- TreeNode.node(at:) navigates a validation coding path to its node using the
  same canonical convention the validators build paths with (a sibling index
  only when a name repeats), and sourceRange(at:) returns the span. So
  Document.validate's ValidationError.codingPath resolves to characters in the
  document.

Tests prove the spans, the navigation, the end-to-end bridge (an XSD finding to
a source range), and that ranged reading of invalid input never crashes.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 13:14:29 +02:00
parent df1d93d21b
commit 4659ea33c8
8 changed files with 184 additions and 1 deletions

View File

@ -66,8 +66,10 @@ flowchart TB
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Validation["#92 OpenAPIKit-idiom validation framework + recovering reader: read invalid documents without crashing, return rich located results"]:::epic
Editor["#94 Editor integration: source spans on the tree, unified lint, expected-here, so findings map to screen positions"]:::epic
Gaps["#71 Beyond-parity libxml2 gaps: DTD entities/defaulting (#72 #73), parser breadth (#74), XSD instance/derivation/wildcards/namespaces (#75 #76 #77 #78), RELAX NG + Schematron (#79 #80), XSLT elements/functions (#81 #82), HTML5 full tree (#83 #84), C14N/catalog/XInclude (#85 #86 #87), XPath/XPointer (#88), regex (#89), DOM/serialization (#90 #91)"]:::epic
Validation --> Gaps
Validation --> Editor
Editor --> Gaps
```
## Status

View File

@ -138,6 +138,19 @@ public extension PureXML {
Parsing.Parser().read(xml, limits: limits, resolver: resolver)
}
/// Reads a possibly-invalid document into a mutable, parent-aware
/// ``Model/TreeNode`` whose nodes carry source spans, plus located
/// ``Parsing/Diagnostic``s. The editor entry point: never throws, recovers in
/// place, and lets a located validation finding be mapped to a source range
/// via ``Model/TreeNode/node(at:)`` and the node's `sourceRange`.
static func readTree(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) -> (tree: Model.TreeNode, diagnostics: [Parsing.Diagnostic]) {
Parsing.Parser().readTree(xml, limits: limits, resolver: resolver)
}
/// Validates a parsed XML node with the default structural rules, throwing a
/// ``PureXML/Validation/ValidationErrorCollection`` that locates every failure
/// by coding path. Pass a custom ``PureXML/Validation/Validator`` to add or

View File

@ -38,6 +38,10 @@ public extension PureXML.Model {
/// The child nodes in document order. Mutated only through the editing
/// methods so the parent links stay consistent.
public internal(set) var children: [TreeNode]
/// The node's span in the source text, when it was produced by the ranged
/// reader (``PureXML/read(asTree:)``); nil for synthesized or edited nodes.
/// Lets a located finding be mapped back to characters in the document.
public internal(set) var sourceRange: PureXML.Parsing.SourceRange?
init(
kind: PureXML.Model.TreeNodeKind,

View File

@ -0,0 +1,24 @@
public extension PureXML.Model.TreeNode {
/// Resolves a validation coding path to the node it addresses, navigating from
/// this node (typically the document) down by element name, using a sibling
/// index only when a name repeats. The same convention the validators build
/// paths with, so a ``PureXML/Validation/ValidationError``'s `codingPath` maps
/// to a node here, and the node's `sourceRange` gives the span to highlight.
/// An attribute step (`@name`) resolves to its owning element.
func node(at path: [PureXML.Validation.PathKey]) -> PureXML.Model.TreeNode? {
var current: PureXML.Model.TreeNode? = self
for key in path {
guard let node = current else { return nil }
if key.stringValue.hasPrefix("@") { return node }
let matches = node.children.filter { $0.kind == .element && $0.name?.description == key.stringValue }
let index = (key.intValue ?? 1) - 1
current = matches.indices.contains(index) ? matches[index] : nil
}
return current
}
/// The source span of the node a validation coding path addresses, when known.
func sourceRange(at path: [PureXML.Validation.PathKey]) -> PureXML.Parsing.SourceRange? {
node(at: path)?.sourceRange
}
}

View File

@ -297,6 +297,11 @@ extension PureXML.Parsing.EventReader {
reader.mark.offset
}
/// The current source position, for attaching spans to ranged tree nodes.
var mark: PureXML.Parsing.Mark {
reader.mark
}
/// Resynchronizes after a failed ``next()`` that started at `offset`, so
/// reading can resume past a recorded diagnostic. Returns false at end of
/// input. If the failed scan already advanced (it consumed the malformed

View File

@ -49,6 +49,18 @@ public extension PureXML.Parsing {
readRecovering(EventReader(xml, limits: limits, resolver: resolver, recovering: true))
}
/// Reads a possibly-invalid document into a mutable, parent-aware tree whose
/// nodes carry source spans, plus located diagnostics. The editor entry
/// point: never throws, recovers in place, and lets a located finding be
/// mapped to a source range through ``Model/TreeNode/node(at:)``.
public func readTree(
_ xml: String,
limits: Limits = .default,
resolver: EntityResolver = .refusing,
) -> (tree: PureXML.Model.TreeNode, diagnostics: [Diagnostic]) {
readTreeRecovering(EventReader(xml, limits: limits, resolver: resolver, recovering: true))
}
/// Parses a single XML document from raw bytes, detecting the encoding
/// (UTF-8 or UTF-16, with or without a byte-order mark) before parsing.
public func parse(bytes: [UInt8], limits: Limits = .default) throws -> PureXML.Model.Node {
@ -172,6 +184,52 @@ public extension PureXML.Parsing {
return ReadResult(node: .document(roots), diagnostics: reader.diagnostics)
}
/// Like ``readRecovering(_:)`` but builds a mutable, parent-aware
/// ``PureXML/Model/TreeNode`` whose every node carries its source span, for
/// editor use. Never throws; returns the best-effort tree and the
/// diagnostics. Each element spans from the `<` of its start tag to just
/// past its end tag (or `/>`); leaf nodes span the text they consumed.
func readTreeRecovering(_ source: EventReader) -> (tree: PureXML.Model.TreeNode, diagnostics: [Diagnostic]) {
var reader = source
let document = PureXML.Model.TreeNode.document()
var stack: [(node: PureXML.Model.TreeNode, start: PureXML.Parsing.Mark)] = []
func attach(_ node: PureXML.Model.TreeNode) {
(stack.last?.node ?? document).append(node)
}
while true {
let start = reader.mark
guard let event = try? reader.next() else { break }
switch event {
case let .startElement(name, attributes):
stack.append((PureXML.Model.TreeNode.element(name, attributes: attributes), start))
case .endElement:
guard let (element, openMark) = stack.popLast() else { continue }
element.sourceRange = PureXML.Parsing.SourceRange(start: openMark, end: reader.mark)
attach(element)
case let .characters(text):
attach(ranged(.text(text), from: start, to: reader.mark))
case let .cdata(text):
attach(ranged(.cdata(text), from: start, to: reader.mark))
case let .comment(text):
attach(ranged(.comment(text), from: start, to: reader.mark))
case let .processingInstruction(target, data):
attach(ranged(.processingInstruction(target: target, data: data), from: start, to: reader.mark))
}
}
while let (element, openMark) = stack.popLast() {
element.sourceRange = PureXML.Parsing.SourceRange(start: openMark, end: reader.mark)
attach(element)
}
return (document, reader.diagnostics)
}
private func ranged(_ node: PureXML.Model.TreeNode, from start: PureXML.Parsing.Mark, to end: PureXML.Parsing.Mark) -> PureXML.Model.TreeNode {
node.sourceRange = PureXML.Parsing.SourceRange(start: start, end: end)
return node
}
private func consume(_ event: Event, into stack: inout [ElementFrame], roots: inout [PureXML.Model.Node]) {
switch event {
case let .startElement(name, attributes):

View File

@ -0,0 +1,20 @@
public extension PureXML.Parsing {
/// A half-open span of source text, from the start of a construct to just past
/// its end. Carried on a ``PureXML/Model/TreeNode`` by the ranged reader so a
/// located finding can be mapped back to characters in the document.
struct SourceRange: Equatable, Sendable, CustomStringConvertible {
/// The position of the first character of the construct.
public var start: Mark
/// The position just past the last character of the construct.
public var end: Mark
public init(start: Mark, end: Mark) {
self.start = start
self.end = end
}
public var description: String {
"\(start.line):\(start.column)-\(end.line):\(end.column)"
}
}
}

View File

@ -0,0 +1,57 @@
@testable import PureXML
import Testing
/// Source spans on the ranged tree, and the bridge that maps a validation
/// finding's coding path to a source range: the keystone for editor use.
@Suite("Source spans")
struct SourceSpanTests {
@Test("Ranged read attaches a source span to each node")
func test_spans() {
let (tree, diagnostics) = PureXML.readTree("<order>\n <qty>x</qty>\n</order>")
#expect(diagnostics.isEmpty)
let order = tree.children.first
#expect(order?.sourceRange?.start.line == 1)
#expect(order?.sourceRange?.start.column == 1)
let qty = tree.node(at: [.element("order"), .element("qty")])
#expect(qty?.value == "")
#expect(qty?.sourceRange?.start == PureXML.Parsing.Mark(line: 2, column: 3, offset: 10))
}
@Test("A coding path resolves to its node, with a sibling index when repeated")
func test_navigation() {
let (tree, _) = PureXML.readTree("<r><i>a</i><i>b</i></r>")
#expect(tree.node(at: [.element("r"), .element("i", index: 2)])?.children.first?.value == "b")
#expect(tree.node(at: [.element("r"), .element("i")])?.children.first?.value == "a")
#expect(tree.node(at: [.element("r"), .element("missing")]) == nil)
}
@Test("A validation finding maps to a source range through the ranged tree")
func test_validationToRange() 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 xml = "<order>\n <qty>lots</qty>\n</order>"
let errors = try PureXML.Schema.Document(xsd).validate(xml)
#expect(errors.count == 1)
// The editor flow: read a ranged tree, then resolve the finding's path to a span.
let (tree, _) = PureXML.readTree(xml)
let range = tree.sourceRange(at: errors[0].codingPath)
#expect(range != nil)
#expect(range?.start == PureXML.Parsing.Mark(line: 2, column: 3, offset: 10))
}
@Test("Ranged read of invalid input never crashes and still spans what parsed")
func test_invalidRanged() {
let (tree, diagnostics) = PureXML.readTree("<a><b>x")
#expect(!diagnostics.isEmpty)
let inner = tree.node(at: [.element("a"), .element("b")])
#expect(inner?.sourceRange != nil)
}
}