From 15de0c53448727e4266603066a27ed4dce88c03b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 03:13:59 +0200 Subject: [PATCH] feat(model): mutable parent-aware tree (libxml2 tree.h parity) Add PureXML.Model.TreeNode, a reference-type document tree that knows its parent and siblings. Navigation goes up and sideways (parent, nextSibling, previousSibling, ancestors, root, elementChildren, stringValue) and editing is in place (append, insert before/after, removeFromParent, replace, copy). Children are held strongly and the parent weakly; attaching a node detaches it from any prior parent and a node can never become its own ancestor. parseTree builds one from XML; TreeNode.node converts back for serialization. Closes #31 --- CHANGELOG.md | 10 ++ README.md | 2 +- Sources/API.swift | 11 ++ Sources/Model/TreeNode.swift | 104 +++++++++++++++++++ Sources/Model/TreeNodeConversion.swift | 47 +++++++++ Sources/Model/TreeNodeMutation.swift | 83 +++++++++++++++ Sources/Model/TreeNodeNavigation.swift | 70 +++++++++++++ Tests/TreeNodeTests.swift | 134 +++++++++++++++++++++++++ 8 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 Sources/Model/TreeNode.swift create mode 100644 Sources/Model/TreeNodeConversion.swift create mode 100644 Sources/Model/TreeNodeMutation.swift create mode 100644 Sources/Model/TreeNodeNavigation.swift create mode 100644 Tests/TreeNodeTests.swift 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) == "") + } +}