PureXML/Sources/Model/TreeNodeNavigation.swift
Mihaela Mihaljevic 48c2c5c7db
fix(xpath): make string-value extraction iterative and comment-correct (#351)
Computing a node's string-value walked its subtree recursively, so it
overflowed the stack on a deeply-nested node (around 2000 deep on a Swift
Task stack): `XPath.Query("/a/a").strings(over:)` over a 4000-deep chain
crashed. `TreeNode.textContent` and the value-Node string-value behind
`Selection.stringValue` now walk an explicit stack, pushing children reversed
to concatenate in document order.

The value-Node path also wrongly folded descendant comment and
processing-instruction data into an element's string-value, disagreeing with
the tree-node path and XPath 1.0 (5.1/5.5: only text and CDATA contribute to
an element's string-value; a comment or PI node's own string-value, 5.6/5.7,
is still its data). It now matches. No test depended on the old behaviour:
1789 pass, including the XPath and XSLT value corpora.

Closes the string-value sites of #350; the transform walks (canonicalize,
DTD defaults, XSLT result tree) remain tracked there.
2026-06-26 20:44:49 +02:00

96 lines
3.5 KiB
Swift

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 XPath string-value of this node. For an element, document, or root it
/// is the concatenated text of the subtree (`textContent`); for a comment or
/// processing instruction it is that node's own character data (XPath 1.0
/// sections 5.6 and 5.7), which `value-of select="comment()"` returns.
var stringValue: String {
switch kind {
case .text, .cdata, .namespace, .comment, .processingInstruction:
value
case .element, .document, .entityReference:
textContent
case .doctype:
""
}
}
/// The concatenated text of every text and CDATA node in this subtree, in
/// document order (the libxml2 `xmlNodeGetContent` behavior): an element's or
/// root's string-value, where comment and processing-instruction descendants
/// contribute nothing (XPath 1.0 sections 5.1 and 5.5). Distinct from
/// `stringValue`, which on a comment or PI node is that node's own data.
var textContent: String {
// Iterative pre-order walk so a deeply-nested subtree does not overflow
// the stack; children are pushed reversed to concatenate in document order.
var result = ""
var stack: [PureXML.Model.TreeNode] = [self]
while let node = stack.popLast() {
switch node.kind {
case .text, .cdata:
result += node.value
case .element, .document, .entityReference:
stack.append(contentsOf: node.children.reversed())
case .namespace, .comment, .processingInstruction, .doctype:
break
}
}
return result
}
/// 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 }
}
}