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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-26 20:44:49 +02:00 committed by GitHub
parent 00c55e642f
commit 48c2c5c7db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 85 additions and 14 deletions

View File

@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Applying DTD attribute defaults to a document (`parseApplyingInternalDTDDefaults`, and the defaults pass behind DTD validation) no longer recurses on the document's depth, so it no longer overflows the stack on a deeply-nested document. The tree is rebuilt bottom-up through a new shared iterative spine (`Node.rebuildingBottomUp`), which the other tree-to-tree transforms will adopt. (#350)
- String-value extraction no longer recurses on a node's depth, so computing the string-value of a deeply-nested node no longer overflows the stack (it crashed as shallow as a couple of thousand deep on a Swift Task stack). `TreeNode.textContent` and the value-`Node` string-value behind `XPath.Query.strings`/`Selection.stringValue` now walk the subtree iteratively. (#350)
- The value-`Node` string-value (behind `XPath.Query.strings` and `Selection.stringValue`) no longer includes the data of descendant comment and processing-instruction nodes in an element's string-value, matching XPath 1.0 (sections 5.1 and 5.5, where only text and CDATA contribute) and the tree-node path. A comment or PI node's own string-value is still its data (5.6, 5.7).
- The value-returning XPath result API (`PureXML.xpath`, `XPath.Query.evaluate`/`elements`/`strings`) is now linear for results that nest within one another, not quadratic. Each result was rebuilt as an independent value `Node` subtree, so selecting a deep chain of nested elements materialized the sum of their subtrees. The projection now shares a per-result-set memo keyed by tree-node identity, so a result reuses the already-built value `Node` of a deeper one by reference (copy-on-write); each tree node is projected once. `//a` over a 8000-deep chain drops from about 19 s to about 0.04 s, and the result set is unchanged. (#348)
- Sorting an XPath result into document order is now linear in the document, not quadratic in its depth. Each node's order key was its root path, an array as long as the node's depth, so ordering the descendants of a deeply-nested document was O(n log n x depth). The key is now a single O(1) index from a per-root pre-order numbering (built once per document and shared through the evaluation cache), with a band ordering an element before its namespace and attribute nodes and its children. A node-set spanning several documents is grouped by root. This linearizes the reference query path (`Query.nodes`, which XSLT and Schematron drive) on deep documents; the underlying subtree walk (`appendSubtree`) is now iterative too, so it no longer recurses. Document order is unchanged.

View File

@ -70,14 +70,21 @@ public extension PureXML.Model.TreeNode {
/// 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 {
switch kind {
case .text, .cdata:
value
case .element, .document, .entityReference:
children.reduce(into: "") { $0 += $1.textContent }
case .namespace, .comment, .processingInstruction, .doctype:
""
// 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

View File

@ -27,14 +27,34 @@ public extension PureXML.XPath {
private static func stringValue(of node: PureXML.Model.Node) -> String {
switch node {
case let .text(value), let .cdata(value), let .comment(value):
value
// A comment or processing-instruction node's own string-value is its
// data (XPath 1.0 5.6, 5.7), but as a descendant of an element it
// contributes nothing (5.1, 5.5): only text and CDATA do.
case let .comment(value):
return value
case let .processingInstruction(_, data):
data
case let .element(element):
element.children.reduce(into: "") { $0 += stringValue(of: $1) }
case let .document(children):
children.reduce(into: "") { $0 += stringValue(of: $1) }
return data
case let .text(value), let .cdata(value):
return value
case .element, .document:
// Iterative pre-order walk so a deeply-nested node does not
// overflow the stack; children are pushed reversed to concatenate
// descendant text in document order.
var result = ""
var stack: [PureXML.Model.Node] = [node]
while let current = stack.popLast() {
switch current {
case let .text(value), let .cdata(value):
result += value
case let .element(element):
stack.append(contentsOf: element.children.reversed())
case let .document(children):
stack.append(contentsOf: children.reversed())
case .comment, .processingInstruction:
break
}
}
return result
}
}
}

View File

@ -0,0 +1,40 @@
import Testing
@testable import PureXML
/// String-value extraction must not recurse on a node's depth (#350). Computing
/// the concatenated character data of an element walked its subtree recursively,
/// so it overflowed the stack on a deeply-nested node (as shallow as a couple of
/// thousand deep on a Swift Task stack, which these tests run on). The walk is
/// now iterative; these tests reach depths the recursive form could not survive.
@Suite("String-value depth safety")
struct StringValueDepthSafetyTests {
private func deepDocument(_ depth: Int, leaf: String) -> PureXML.Model.Node {
var node: PureXML.Model.Node = .text(leaf)
for index in 0 ..< depth {
// A comment sibling at each level proves comments do not contribute to
// the XPath string-value of an element.
node = .element(.init("a", children: [.comment("c\(index)"), node]))
}
return .document([node])
}
@Test("strings() on a 50k-deep node returns the leaf text without overflowing")
func test_deepStringValueIsBounded() throws {
let document = deepDocument(50000, leaf: "leaf")
let strings = try PureXML.XPath.Query("/a").strings(over: document)
#expect(strings == ["leaf"]) // comments excluded from an element's string-value
}
@Test("TreeNode.textContent on a deep subtree is iterative and excludes comments")
func test_deepTextContentIsBounded() {
let tree = PureXML.Model.TreeNode(deepDocument(50000, leaf: "deep"))
#expect(tree.textContent == "deep")
}
@Test("string-value concatenates descendant text in document order")
func test_stringValueOrderAndContent() throws {
let xml = "<r>one<a>two</a>three<b>four</b></r>"
let strings = try PureXML.XPath.Query("/r").strings(over: PureXML.parse(xml))
#expect(strings == ["onetwothreefour"])
}
}