diff --git a/README.md b/README.md
index 451dff5..9e077c4 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Sources/API.swift b/Sources/API.swift
index 00ea966..a1b3364 100644
--- a/Sources/API.swift
+++ b/Sources/API.swift
@@ -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
diff --git a/Sources/Model/TreeNode.swift b/Sources/Model/TreeNode.swift
index 8d99e86..c964410 100644
--- a/Sources/Model/TreeNode.swift
+++ b/Sources/Model/TreeNode.swift
@@ -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,
diff --git a/Sources/Model/TreeNodeSpan.swift b/Sources/Model/TreeNodeSpan.swift
new file mode 100644
index 0000000..ab4ee98
--- /dev/null
+++ b/Sources/Model/TreeNodeSpan.swift
@@ -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
+ }
+}
diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift
index 3e1e203..1e9a6fc 100644
--- a/Sources/Parsing/EventReader.swift
+++ b/Sources/Parsing/EventReader.swift
@@ -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
diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift
index 48e6de5..6c0d3e5 100644
--- a/Sources/Parsing/Parser.swift
+++ b/Sources/Parsing/Parser.swift
@@ -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):
diff --git a/Sources/Parsing/SourceRange.swift b/Sources/Parsing/SourceRange.swift
new file mode 100644
index 0000000..b7a5f2e
--- /dev/null
+++ b/Sources/Parsing/SourceRange.swift
@@ -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)"
+ }
+ }
+}
diff --git a/Tests/SourceSpanTests.swift b/Tests/SourceSpanTests.swift
new file mode 100644
index 0000000..043aa2b
--- /dev/null
+++ b/Tests/SourceSpanTests.swift
@@ -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("\n x\n")
+ #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("ab")
+ #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 = """
+
+
+
+
+
+
+
+ """
+ let xml = "\n lots\n"
+ 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("x")
+ #expect(!diagnostics.isEmpty)
+ let inner = tree.node(at: [.element("a"), .element("b")])
+ #expect(inner?.sourceRange != nil)
+ }
+}