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