diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d10ecb..517b7f9 100644
--- a/CHANGELOG.md
+++ b/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 (``) are stored
and expanded within the DTD, including bare `%name;` references that inject
diff --git a/README.md b/README.md
index a3c4c1b..68bdcf5 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Sources/API.swift b/Sources/API.swift
index 49f760b..bb1a8b9 100644
--- a/Sources/API.swift
+++ b/Sources/API.swift
@@ -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(
diff --git a/Sources/Model/TreeNode.swift b/Sources/Model/TreeNode.swift
new file mode 100644
index 0000000..8d99e86
--- /dev/null
+++ b/Sources/Model/TreeNode.swift
@@ -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)
+ }
+ }
+}
diff --git a/Sources/Model/TreeNodeConversion.swift b/Sources/Model/TreeNodeConversion.swift
new file mode 100644
index 0000000..3d8e571
--- /dev/null
+++ b/Sources/Model/TreeNodeConversion.swift
@@ -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)
+ }
+ }
+}
diff --git a/Sources/Model/TreeNodeMutation.swift b/Sources/Model/TreeNodeMutation.swift
new file mode 100644
index 0000000..a9f75e8
--- /dev/null
+++ b/Sources/Model/TreeNodeMutation.swift
@@ -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
+ }
+}
diff --git a/Sources/Model/TreeNodeNavigation.swift b/Sources/Model/TreeNodeNavigation.swift
new file mode 100644
index 0000000..75fd10a
--- /dev/null
+++ b/Sources/Model/TreeNodeNavigation.swift
@@ -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 }
+ }
+}
diff --git a/Tests/TreeNodeTests.swift b/Tests/TreeNodeTests.swift
new file mode 100644
index 0000000..d1d36de
--- /dev/null
+++ b/Tests/TreeNodeTests.swift
@@ -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("")
+ 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("")
+ 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("ab")
+ #expect(tree.firstChild?.stringValue == "abc")
+ }
+
+ @Test("elementChildren skips non-element nodes")
+ func test_elementChildren() throws {
+ let tree = try PureXML.parseTree("text")
+ #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 = "text"
+ 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("")
+ tree.firstChild?.append(Tree.element("b"))
+ #expect(PureXML.serialize(tree.node, options: .compact) == "")
+ }
+}