feat(model): TreeNode editing helpers (attributes, shallow copy, order, normalize)
Add the DOM/tree editing primitives from the audit that the editor use case needs: - Attribute helpers: attributeValue(_:), setAttribute(_:_:) (update in place or append), removeAttribute(_:). - shallowCopy() copies a node alone, complementing the deep copy(). - precedes(_:) compares two nodes in document order (ancestor before descendant, earlier sibling before later; unrelated nodes compare false). - normalize() coalesces adjacent text children, drops empty text, and recurses into elements; CDATA is left intact. First-class namespace/DOCTYPE/entity-reference nodes (an invasive TreeNodeKind change) and cross-document adoption/ownerDocument (no document-identity model) are left for a follow-up. Closes #90
This commit is contained in:
parent
0fb7d08303
commit
741e65cae1
99
Sources/Model/TreeNodeEditing.swift
Normal file
99
Sources/Model/TreeNodeEditing.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
97
Tests/TreeNodeEditingTests.swift
Normal file
97
Tests/TreeNodeEditingTests.swift
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user