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:
parent
df1d93d21b
commit
4659ea33c8
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
24
Sources/Model/TreeNodeSpan.swift
Normal file
24
Sources/Model/TreeNodeSpan.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
20
Sources/Parsing/SourceRange.swift
Normal file
20
Sources/Parsing/SourceRange.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
57
Tests/SourceSpanTests.swift
Normal file
57
Tests/SourceSpanTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user