diff --git a/CHANGELOG.md b/CHANGELOG.md index b686359..6131900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/Model/TreeNodeNavigation.swift b/Sources/Model/TreeNodeNavigation.swift index 3373ca2..59db91b 100644 --- a/Sources/Model/TreeNodeNavigation.swift +++ b/Sources/Model/TreeNodeNavigation.swift @@ -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 diff --git a/Sources/XPath/Selection.swift b/Sources/XPath/Selection.swift index b67ada4..4b27bae 100644 --- a/Sources/XPath/Selection.swift +++ b/Sources/XPath/Selection.swift @@ -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 } } } diff --git a/Tests/StringValueDepthSafetyTests.swift b/Tests/StringValueDepthSafetyTests.swift new file mode 100644 index 0000000..ac5a7cf --- /dev/null +++ b/Tests/StringValueDepthSafetyTests.swift @@ -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 = "onetwothreefour" + let strings = try PureXML.XPath.Query("/r").strings(over: PureXML.parse(xml)) + #expect(strings == ["onetwothreefour"]) + } +}