Merge epic/31-tree: mutable parent-aware tree (libxml2 tree.h parity)
This commit is contained in:
commit
dfd685fb15
10
CHANGELOG.md
10
CHANGELOG.md
@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Mutable, parent-aware document tree (the libxml2 `tree.h` model).
|
||||
`PureXML.Model.TreeNode` is a reference type that knows its parent and
|
||||
siblings, so a document can be navigated upward and sideways (`parent`,
|
||||
`nextSibling`, `previousSibling`, `ancestors`, `root`, `elementChildren`,
|
||||
`stringValue`) and edited in place (`append`, `insert(before:)`,
|
||||
`insert(after:)`, `removeFromParent`, `replace(with:)`, `copy`). Children are
|
||||
held strongly and the parent weakly; attaching a node detaches it from any
|
||||
previous parent, and a node can never become its own ancestor. `parseTree(_:)`
|
||||
builds one from XML and `TreeNode.node` converts back to the value tree for
|
||||
serialization.
|
||||
- External and parameter entities (the libxml2 `entities.h` model), secure by
|
||||
default. Internal parameter entities (`<!ENTITY % name "value">`) are stored
|
||||
and expanded within the DTD, including bare `%name;` references that inject
|
||||
|
||||
@ -61,7 +61,7 @@ flowchart TB
|
||||
Html["#20 HTML parser and serializer"]:::epic
|
||||
Enc["#28 Full encoding support"]:::done
|
||||
Ent["#29 External and parameter entities"]:::done
|
||||
TreeApi["#31 Tree manipulation API"]:::epic
|
||||
TreeApi["#31 Tree manipulation API"]:::done
|
||||
Writer["#32 xmlTextWriter"]:::done
|
||||
Reader["#33 xmlTextReader with validation"]:::epic
|
||||
Save["#34 Serialization option parity"]:::done
|
||||
|
||||
@ -107,6 +107,17 @@ public extension PureXML {
|
||||
Emitting.Serializer(options: options).serialize(node)
|
||||
}
|
||||
|
||||
/// Parses an XML document into a mutable, parent-aware ``Model/TreeNode`` for
|
||||
/// in-place editing (insert, remove, replace, copy, and upward navigation).
|
||||
/// Serialize the result back with `serialize(tree.node)`.
|
||||
static func parseTree(
|
||||
_ xml: String,
|
||||
limits: Parsing.Limits = .default,
|
||||
resolver: Parsing.EntityResolver = .refusing,
|
||||
) throws -> Model.TreeNode {
|
||||
try Model.TreeNode(parse(xml, limits: limits, resolver: resolver))
|
||||
}
|
||||
|
||||
/// Validates a parsed XML node with the default validation rules.
|
||||
@discardableResult
|
||||
static func validate(
|
||||
|
||||
104
Sources/Model/TreeNode.swift
Normal file
104
Sources/Model/TreeNode.swift
Normal file
@ -0,0 +1,104 @@
|
||||
public extension PureXML.Model {
|
||||
/// The kind of a ``TreeNode``, mirroring ``Node``'s cases. Carried as a tag
|
||||
/// so a node can be navigated and edited uniformly regardless of kind.
|
||||
enum TreeNodeKind: Equatable, Sendable {
|
||||
case document
|
||||
case element
|
||||
case text
|
||||
case cdata
|
||||
case comment
|
||||
case processingInstruction
|
||||
}
|
||||
|
||||
/// A mutable, parent-aware XML tree node (the libxml2 `tree.h` model).
|
||||
///
|
||||
/// Unlike the value-typed ``Node``, which is immutable and holds no upward
|
||||
/// links, a `TreeNode` is a reference type that knows its parent and siblings,
|
||||
/// so a document can be navigated in every direction and edited in place:
|
||||
/// insert, remove, replace, and copy. Children are held strongly and the
|
||||
/// parent weakly, so a detached subtree is freed once no one references it.
|
||||
///
|
||||
/// Build one from a parsed ``Node`` with ``init(_:)`` and convert back with
|
||||
/// ``node`` to serialize. A node has exactly one parent: attaching a node that
|
||||
/// already has one detaches it first, so the tree can never become a DAG.
|
||||
final class TreeNode {
|
||||
/// The node kind. Fixed for a node's lifetime; change kind by replacing
|
||||
/// the node.
|
||||
public let kind: PureXML.Model.TreeNodeKind
|
||||
/// The element name (or the processing-instruction target), or nil for
|
||||
/// text, CDATA, comment, and document nodes.
|
||||
public var name: PureXML.Model.QualifiedName?
|
||||
/// The element's attributes; empty for non-element nodes.
|
||||
public var attributes: [PureXML.Model.Attribute]
|
||||
/// The character payload: text/CDATA/comment content, or PI data. Empty
|
||||
/// for element and document nodes.
|
||||
public var value: String
|
||||
/// The parent node, or nil for a root or detached node. Held weakly.
|
||||
public internal(set) weak var parent: TreeNode?
|
||||
/// The child nodes in document order. Mutated only through the editing
|
||||
/// methods so the parent links stay consistent.
|
||||
public internal(set) var children: [TreeNode]
|
||||
|
||||
init(
|
||||
kind: PureXML.Model.TreeNodeKind,
|
||||
name: PureXML.Model.QualifiedName? = nil,
|
||||
attributes: [PureXML.Model.Attribute] = [],
|
||||
value: String = "",
|
||||
children: [TreeNode] = [],
|
||||
) {
|
||||
self.kind = kind
|
||||
self.name = name
|
||||
self.attributes = attributes
|
||||
self.value = value
|
||||
self.children = []
|
||||
for child in children {
|
||||
append(child)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Factories
|
||||
|
||||
/// Creates a document node holding the given top-level children.
|
||||
public static func document(children: [TreeNode] = []) -> TreeNode {
|
||||
TreeNode(kind: .document, children: children)
|
||||
}
|
||||
|
||||
/// Creates an element node.
|
||||
public static func element(
|
||||
_ name: PureXML.Model.QualifiedName,
|
||||
attributes: [PureXML.Model.Attribute] = [],
|
||||
children: [TreeNode] = [],
|
||||
) -> TreeNode {
|
||||
TreeNode(kind: .element, name: name, attributes: attributes, children: children)
|
||||
}
|
||||
|
||||
/// Creates an element node from a raw `prefix:local` name.
|
||||
public static func element(
|
||||
_ name: String,
|
||||
attributes: [PureXML.Model.Attribute] = [],
|
||||
children: [TreeNode] = [],
|
||||
) -> TreeNode {
|
||||
element(PureXML.Model.QualifiedName(name), attributes: attributes, children: children)
|
||||
}
|
||||
|
||||
/// Creates a text node.
|
||||
public static func text(_ value: String) -> TreeNode {
|
||||
TreeNode(kind: .text, value: value)
|
||||
}
|
||||
|
||||
/// Creates a CDATA-section node.
|
||||
public static func cdata(_ value: String) -> TreeNode {
|
||||
TreeNode(kind: .cdata, value: value)
|
||||
}
|
||||
|
||||
/// Creates a comment node.
|
||||
public static func comment(_ value: String) -> TreeNode {
|
||||
TreeNode(kind: .comment, value: value)
|
||||
}
|
||||
|
||||
/// Creates a processing-instruction node.
|
||||
public static func processingInstruction(target: String, data: String) -> TreeNode {
|
||||
TreeNode(kind: .processingInstruction, name: PureXML.Model.QualifiedName(target), value: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Sources/Model/TreeNodeConversion.swift
Normal file
47
Sources/Model/TreeNodeConversion.swift
Normal file
@ -0,0 +1,47 @@
|
||||
public extension PureXML.Model.TreeNode {
|
||||
/// Builds a mutable tree from a parsed value ``Node``, wiring up parent links.
|
||||
convenience init(_ node: PureXML.Model.Node) {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
self.init(kind: .document, children: children.map(PureXML.Model.TreeNode.init))
|
||||
case let .element(element):
|
||||
self.init(
|
||||
kind: .element,
|
||||
name: element.name,
|
||||
attributes: element.attributes,
|
||||
children: element.children.map(PureXML.Model.TreeNode.init),
|
||||
)
|
||||
case let .text(value):
|
||||
self.init(kind: .text, value: value)
|
||||
case let .cdata(value):
|
||||
self.init(kind: .cdata, value: value)
|
||||
case let .comment(value):
|
||||
self.init(kind: .comment, value: value)
|
||||
case let .processingInstruction(target, data):
|
||||
self.init(kind: .processingInstruction, name: PureXML.Model.QualifiedName(target), value: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds an immutable value ``Node`` from this subtree, for serialization
|
||||
/// or value comparison.
|
||||
var node: PureXML.Model.Node {
|
||||
switch kind {
|
||||
case .document:
|
||||
.document(children.map(\.node))
|
||||
case .element:
|
||||
.element(PureXML.Model.Element(
|
||||
name: name ?? PureXML.Model.QualifiedName(""),
|
||||
attributes: attributes,
|
||||
children: children.map(\.node),
|
||||
))
|
||||
case .text:
|
||||
.text(value)
|
||||
case .cdata:
|
||||
.cdata(value)
|
||||
case .comment:
|
||||
.comment(value)
|
||||
case .processingInstruction:
|
||||
.processingInstruction(target: name?.description ?? "", data: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
83
Sources/Model/TreeNodeMutation.swift
Normal file
83
Sources/Model/TreeNodeMutation.swift
Normal file
@ -0,0 +1,83 @@
|
||||
public extension PureXML.Model.TreeNode {
|
||||
/// Appends `child` as the last child, detaching it from any previous parent.
|
||||
func append(_ child: PureXML.Model.TreeNode) {
|
||||
insert(child, at: children.count)
|
||||
}
|
||||
|
||||
/// Inserts `child` at `index` among the children (clamped to a valid range),
|
||||
/// detaching it from any previous parent first. Refuses to attach a node to
|
||||
/// itself or to one of its own descendants, which would form a cycle.
|
||||
func insert(_ child: PureXML.Model.TreeNode, at index: Int) {
|
||||
guard child !== self, !isDescendant(of: child) else { return }
|
||||
child.removeFromParent()
|
||||
let target = Swift.max(0, Swift.min(index, children.count))
|
||||
children.insert(child, at: target)
|
||||
child.parent = self
|
||||
}
|
||||
|
||||
/// Inserts `child` immediately before `sibling` among this node's children.
|
||||
/// No-op if `sibling` is not a child of this node.
|
||||
func insert(_ child: PureXML.Model.TreeNode, before sibling: PureXML.Model.TreeNode) {
|
||||
guard let index = indexOfChild(sibling) else { return }
|
||||
insert(child, at: index)
|
||||
}
|
||||
|
||||
/// Inserts `child` immediately after `sibling` among this node's children.
|
||||
/// No-op if `sibling` is not a child of this node.
|
||||
func insert(_ child: PureXML.Model.TreeNode, after sibling: PureXML.Model.TreeNode) {
|
||||
guard let index = indexOfChild(sibling) else { return }
|
||||
insert(child, at: index + 1)
|
||||
}
|
||||
|
||||
/// Detaches this node from its parent, leaving it a standalone root.
|
||||
func removeFromParent() {
|
||||
guard let parent, let index = parent.indexOfChild(self) else { return }
|
||||
parent.children.remove(at: index)
|
||||
self.parent = nil
|
||||
}
|
||||
|
||||
/// Removes `child` from this node's children. No-op if it is not a child.
|
||||
func removeChild(_ child: PureXML.Model.TreeNode) {
|
||||
guard child.parent === self else { return }
|
||||
child.removeFromParent()
|
||||
}
|
||||
|
||||
/// Replaces this node in its parent with `replacement`, returning self
|
||||
/// detached. No-op (returns self) if this node has no parent.
|
||||
@discardableResult
|
||||
func replace(with replacement: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode {
|
||||
guard let parent, let index = parent.indexOfChild(self) else { return self }
|
||||
parent.insert(replacement, at: index)
|
||||
removeFromParent()
|
||||
return self
|
||||
}
|
||||
|
||||
/// Returns a deep copy of this subtree with no parent. Children are copied
|
||||
/// recursively; the copy shares no nodes with the original.
|
||||
func copy() -> PureXML.Model.TreeNode {
|
||||
let clone = PureXML.Model.TreeNode(
|
||||
kind: kind,
|
||||
name: name,
|
||||
attributes: attributes,
|
||||
value: value,
|
||||
)
|
||||
for child in children {
|
||||
clone.append(child.copy())
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
private func indexOfChild(_ child: PureXML.Model.TreeNode) -> Int? {
|
||||
children.firstIndex { $0 === child }
|
||||
}
|
||||
|
||||
/// Whether this node is `ancestor` itself or appears below it.
|
||||
private func isDescendant(of ancestor: PureXML.Model.TreeNode) -> Bool {
|
||||
var current: PureXML.Model.TreeNode? = self
|
||||
while let node = current {
|
||||
if node === ancestor { return true }
|
||||
current = node.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
70
Sources/Model/TreeNodeNavigation.swift
Normal file
70
Sources/Model/TreeNodeNavigation.swift
Normal file
@ -0,0 +1,70 @@
|
||||
public extension PureXML.Model.TreeNode {
|
||||
/// The first child, or nil when there are none.
|
||||
var firstChild: PureXML.Model.TreeNode? {
|
||||
children.first
|
||||
}
|
||||
|
||||
/// The last child, or nil when there are none.
|
||||
var lastChild: PureXML.Model.TreeNode? {
|
||||
children.last
|
||||
}
|
||||
|
||||
/// The next sibling in document order, or nil when this is the last child or
|
||||
/// has no parent.
|
||||
var nextSibling: PureXML.Model.TreeNode? {
|
||||
guard let siblings = parent?.children, let index = indexInParent else { return nil }
|
||||
let next = index + 1
|
||||
return next < siblings.count ? siblings[next] : nil
|
||||
}
|
||||
|
||||
/// The previous sibling in document order, or nil when this is the first child
|
||||
/// or has no parent.
|
||||
var previousSibling: PureXML.Model.TreeNode? {
|
||||
guard let siblings = parent?.children, let index = indexInParent, index > 0 else { return nil }
|
||||
return siblings[index - 1]
|
||||
}
|
||||
|
||||
/// The element children only, skipping text, comments, and the rest.
|
||||
var elementChildren: [PureXML.Model.TreeNode] {
|
||||
children.filter { $0.kind == .element }
|
||||
}
|
||||
|
||||
/// The chain of ancestors from the immediate parent up to the root.
|
||||
var ancestors: [PureXML.Model.TreeNode] {
|
||||
var result: [PureXML.Model.TreeNode] = []
|
||||
var current = parent
|
||||
while let node = current {
|
||||
result.append(node)
|
||||
current = node.parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The topmost ancestor (the node itself when it has no parent).
|
||||
var root: PureXML.Model.TreeNode {
|
||||
var current = self
|
||||
while let next = current.parent {
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/// The concatenated text of every text and CDATA node in this subtree, in
|
||||
/// document order (the libxml2 `xmlNodeGetContent` behavior).
|
||||
var stringValue: String {
|
||||
switch kind {
|
||||
case .text, .cdata:
|
||||
value
|
||||
case .element, .document:
|
||||
children.reduce(into: "") { $0 += $1.stringValue }
|
||||
case .comment, .processingInstruction:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/// This node's position among its parent's children, or nil when it has no
|
||||
/// parent.
|
||||
private var indexInParent: Int? {
|
||||
parent?.children.firstIndex { $0 === self }
|
||||
}
|
||||
}
|
||||
134
Tests/TreeNodeTests.swift
Normal file
134
Tests/TreeNodeTests.swift
Normal file
@ -0,0 +1,134 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Mutable tree")
|
||||
struct TreeNodeTests {
|
||||
private typealias Tree = PureXML.Model.TreeNode
|
||||
|
||||
// MARK: Navigation
|
||||
|
||||
@Test("Parent and sibling links are wired from a parsed tree")
|
||||
func test_navigation() throws {
|
||||
let tree = try PureXML.parseTree("<r><a/><b/><c/></r>")
|
||||
let root = tree.firstChild
|
||||
let children = root?.children ?? []
|
||||
#expect(children.count == 3)
|
||||
#expect(children[1].parent === root)
|
||||
#expect(children[0].nextSibling === children[1])
|
||||
#expect(children[2].previousSibling === children[1])
|
||||
#expect(children[0].previousSibling == nil)
|
||||
#expect(children[2].nextSibling == nil)
|
||||
}
|
||||
|
||||
@Test("Ancestors and root walk upward")
|
||||
func test_ancestors() throws {
|
||||
let tree = try PureXML.parseTree("<r><a><b/></a></r>")
|
||||
let deep = tree.firstChild?.firstChild?.firstChild
|
||||
#expect(deep?.name?.localName == "b")
|
||||
#expect(deep?.ancestors.map { $0.name?.localName ?? "#" } == ["a", "r", "#"])
|
||||
#expect(deep?.root === tree)
|
||||
}
|
||||
|
||||
@Test("stringValue concatenates descendant text and CDATA")
|
||||
func test_stringValue() throws {
|
||||
let tree = try PureXML.parseTree("<r>a<x>b</x><![CDATA[c]]></r>")
|
||||
#expect(tree.firstChild?.stringValue == "abc")
|
||||
}
|
||||
|
||||
@Test("elementChildren skips non-element nodes")
|
||||
func test_elementChildren() throws {
|
||||
let tree = try PureXML.parseTree("<r>text<a/><!--c--><b/></r>")
|
||||
#expect(tree.firstChild?.elementChildren.map { $0.name?.localName } == ["a", "b"])
|
||||
}
|
||||
|
||||
// MARK: Mutation
|
||||
|
||||
@Test("append moves a node and updates its parent")
|
||||
func test_append() {
|
||||
let root = Tree.element("r")
|
||||
let child = Tree.element("a")
|
||||
root.append(child)
|
||||
#expect(child.parent === root)
|
||||
#expect(root.children.count == 1)
|
||||
}
|
||||
|
||||
@Test("Attaching a node detaches it from its previous parent")
|
||||
func test_reparenting() {
|
||||
let first = Tree.element("first")
|
||||
let second = Tree.element("second")
|
||||
let child = Tree.text("x")
|
||||
first.append(child)
|
||||
second.append(child)
|
||||
#expect(first.children.isEmpty)
|
||||
#expect(second.children.count == 1)
|
||||
#expect(child.parent === second)
|
||||
}
|
||||
|
||||
@Test("insert before and after place a node correctly")
|
||||
func test_insertRelative() {
|
||||
let root = Tree.element("r")
|
||||
let first = Tree.element("a")
|
||||
let last = Tree.element("c")
|
||||
root.append(first)
|
||||
root.append(last)
|
||||
root.insert(Tree.element("b"), after: first)
|
||||
root.insert(Tree.element("start"), before: first)
|
||||
#expect(root.children.map { $0.name?.localName } == ["start", "a", "b", "c"])
|
||||
}
|
||||
|
||||
@Test("removeFromParent detaches a node")
|
||||
func test_remove() {
|
||||
let root = Tree.element("r")
|
||||
let child = Tree.element("a")
|
||||
root.append(child)
|
||||
child.removeFromParent()
|
||||
#expect(root.children.isEmpty)
|
||||
#expect(child.parent == nil)
|
||||
}
|
||||
|
||||
@Test("replace swaps a node in place")
|
||||
func test_replace() {
|
||||
let root = Tree.element("r")
|
||||
let old = Tree.element("old")
|
||||
root.append(old)
|
||||
old.replace(with: Tree.element("new"))
|
||||
#expect(root.children.map { $0.name?.localName } == ["new"])
|
||||
#expect(old.parent == nil)
|
||||
}
|
||||
|
||||
@Test("copy is a deep clone sharing no nodes")
|
||||
func test_copy() {
|
||||
let root = Tree.element("r", children: [Tree.element("a", children: [Tree.text("x")])])
|
||||
let clone = root.copy()
|
||||
clone.firstChild?.firstChild?.value = "y"
|
||||
#expect(root.firstChild?.firstChild?.value == "x")
|
||||
#expect(clone.parent == nil)
|
||||
#expect(clone.firstChild !== root.firstChild)
|
||||
}
|
||||
|
||||
@Test("A node cannot be made its own descendant")
|
||||
func test_noCycles() {
|
||||
let parent = Tree.element("p")
|
||||
let child = Tree.element("c")
|
||||
parent.append(child)
|
||||
child.append(parent)
|
||||
#expect(child.children.isEmpty)
|
||||
#expect(parent.children.count == 1)
|
||||
}
|
||||
|
||||
// MARK: Round-trip
|
||||
|
||||
@Test("A parsed tree round-trips back to identical XML")
|
||||
func test_roundTrip() throws {
|
||||
let xml = "<r a=\"1\"><b>text</b><c/></r>"
|
||||
let tree = try PureXML.parseTree(xml)
|
||||
#expect(PureXML.serialize(tree.node, options: .compact) == xml)
|
||||
}
|
||||
|
||||
@Test("Edits show up in the serialized output")
|
||||
func test_editThenSerialize() throws {
|
||||
let tree = try PureXML.parseTree("<r><a/></r>")
|
||||
tree.firstChild?.append(Tree.element("b"))
|
||||
#expect(PureXML.serialize(tree.node, options: .compact) == "<r><a/><b/></r>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user