diff --git a/Sources/Model/TreeNodeEditing.swift b/Sources/Model/TreeNodeEditing.swift new file mode 100644 index 0000000..99a4788 --- /dev/null +++ b/Sources/Model/TreeNodeEditing.swift @@ -0,0 +1,99 @@ +public extension PureXML.Model.TreeNode { + // MARK: Attribute helpers + + /// The value of the attribute named `name` (by `prefix:local` rendering or by + /// local name), or nil when the element has no such attribute. + func attributeValue(_ name: String) -> String? { + attributes.first { $0.name.description == name || $0.name.localName == name }?.value + } + + /// Sets the attribute named `name` to `value`, updating it in place when it is + /// already present (matched by `prefix:local` rendering) or appending it + /// otherwise. A no-op on a non-element node. + func setAttribute(_ name: String, _ value: String) { + guard kind == .element else { return } + if let index = attributes.firstIndex(where: { $0.name.description == name }) { + attributes[index].value = value + } else { + attributes.append(PureXML.Model.Attribute(name, value)) + } + } + + /// Removes the attribute named `name` (by `prefix:local` rendering or local + /// name), returning whether one was removed. + @discardableResult + func removeAttribute(_ name: String) -> Bool { + guard let index = attributes.firstIndex(where: { $0.name.description == name || $0.name.localName == name }) else { + return false + } + attributes.remove(at: index) + return true + } + + // MARK: Copying + + /// Returns a copy of this node alone, with no children and no parent. The + /// element's name, attributes, and value are carried over. Complements the + /// deep ``copy()``. + func shallowCopy() -> PureXML.Model.TreeNode { + PureXML.Model.TreeNode(kind: kind, name: name, attributes: attributes, value: value) + } + + // MARK: Document order + + /// Whether this node strictly precedes `other` in document order. Two nodes are + /// comparable only when they share a root; an unrelated node returns false. An + /// ancestor precedes its descendants. + func precedes(_ other: PureXML.Model.TreeNode) -> Bool { + if self === other { return false } + let mine = Self.pathFromRoot(self) + let theirs = Self.pathFromRoot(other) + guard mine.first === theirs.first else { return false } + var depth = 0 + while depth < mine.count, depth < theirs.count, mine[depth] === theirs[depth] { + depth += 1 + } + if depth == mine.count { return true } + if depth == theirs.count { return false } + let parent = mine[depth - 1] + let mineIndex = parent.children.firstIndex { $0 === mine[depth] } ?? 0 + let theirsIndex = parent.children.firstIndex { $0 === theirs[depth] } ?? 0 + return mineIndex < theirsIndex + } + + private static func pathFromRoot(_ node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] { + var path = [node] + var current = node + while let parent = current.parent { + path.append(parent) + current = parent + } + return path.reversed() + } + + // MARK: Normalization + + /// Coalesces adjacent text children into one, drops empty text children, and + /// normalizes each element child recursively (the DOM `normalize` behavior). + /// CDATA sections are left intact and never merged with text. + func normalize() { + var result: [PureXML.Model.TreeNode] = [] + for child in children { + if child.kind == .text { + if child.value.isEmpty { + child.parent = nil + continue + } + if let last = result.last, last.kind == .text { + last.value += child.value + child.parent = nil + continue + } + } else { + child.normalize() + } + result.append(child) + } + children = result + } +} diff --git a/Tests/TreeNodeEditingTests.swift b/Tests/TreeNodeEditingTests.swift new file mode 100644 index 0000000..1579c09 --- /dev/null +++ b/Tests/TreeNodeEditingTests.swift @@ -0,0 +1,97 @@ +@testable import PureXML +import Testing + +@Suite("TreeNode editing: attributes, copy, order, normalize") +struct TreeNodeEditingTests { + private typealias Tree = PureXML.Model.TreeNode + + @Test("Attribute get, set, and remove") + func test_attributes() { + let element = Tree.element("a", attributes: [PureXML.Model.Attribute("id", "1")]) + #expect(element.attributeValue("id") == "1") + element.setAttribute("id", "2") + #expect(element.attributeValue("id") == "2") + element.setAttribute("class", "x") + #expect(element.attributeValue("class") == "x") + #expect(element.attributes.count == 2) + let removed = element.removeAttribute("id") + #expect(removed) + #expect(element.attributeValue("id") == nil) + let removedAgain = element.removeAttribute("id") + #expect(!removedAgain) + } + + @Test("setAttribute is a no-op on a non-element node") + func test_setAttributeOnText() { + let text = Tree.text("hi") + text.setAttribute("id", "1") + #expect(text.attributes.isEmpty) + } + + @Test("shallowCopy copies the node without its children or parent") + func test_shallowCopy() { + let parent = Tree.element("a", children: [Tree.element("b")]) + let child = parent.children[0] + child.setAttribute("k", "v") + let copy = child.shallowCopy() + #expect(copy.name?.localName == "b") + #expect(copy.attributeValue("k") == "v") + #expect(copy.children.isEmpty) + #expect(copy.parent == nil) + } + + @Test("Document order: ancestor precedes descendant and earlier siblings precede later") + func test_documentOrder() { + let root = Tree.element("a") + let first = Tree.element("b") + let second = Tree.element("c") + root.append(first) + root.append(second) + let deep = Tree.element("d") + first.append(deep) + #expect(root.precedes(first)) // ancestor before descendant + #expect(first.precedes(second)) // earlier sibling before later + #expect(deep.precedes(second)) // deep node before its parent's later sibling + #expect(!second.precedes(first)) + #expect(!root.precedes(root)) + } + + @Test("Document order across unrelated trees is false") + func test_documentOrderUnrelated() { + let one = Tree.element("x") + let two = Tree.element("y") + #expect(!one.precedes(two)) + #expect(!two.precedes(one)) + } + + @Test("normalize coalesces adjacent text and drops empty text") + func test_normalize() { + let element = Tree.element("a") + element.append(Tree.text("foo")) + element.append(Tree.text("")) + element.append(Tree.text("bar")) + element.append(Tree.comment("c")) + element.append(Tree.text("baz")) + element.normalize() + #expect(element.children.count == 3) // "foobar", comment, "baz" + #expect(element.children[0].kind == .text) + #expect(element.children[0].value == "foobar") + #expect(element.children[1].kind == .comment) + #expect(element.children[2].value == "baz") + } + + @Test("normalize does not merge CDATA with text and recurses into elements") + func test_normalizeCDATAandRecursion() { + let inner = Tree.element("b") + inner.append(Tree.text("x")) + inner.append(Tree.text("y")) + let element = Tree.element("a") + element.append(Tree.text("t")) + element.append(Tree.cdata("data")) + element.append(inner) + element.normalize() + #expect(element.children.count == 3) // text, cdata, element (all distinct) + #expect(inner.children.count == 1) + #expect(inner.children[0].value == "xy") + } +}