Merge epic/31-tree: mutable parent-aware tree (libxml2 tree.h parity)

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 03:14:03 +02:00
commit dfd685fb15
8 changed files with 460 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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(

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

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

View 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
}
}

View 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
View 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>")
}
}