perf(xslt): make xsl:number level="any" quadratic, not cubic (#309)

walkPreceding visits a node and everything before it in document order. It found
each preceding sibling with precedingSibling(of:), which rescans the parent's
whole child list, so visiting k preceding siblings was O(k^2) and numbering n
nodes under a wide parent was O(n^3): 1000 items took ~4.7s and 4000 did not
finish in minutes.

Find the candidate's index in the child list once, then walk the children before
it directly (parent.children[..<index].reversed()), visiting each preceding
subtree in reverse. The walk is now linear in the number of preceding nodes, so
the numbering is quadratic (the residual is inherent to level="any", which
counts the matching preceding nodes per numbered node). The numbers are
identical.

Numbering 1000 items drops ~4.7s to ~0.7s (~6x); cubic becomes quadratic. The
full suite, the W3C XSLT and Apache Xalan corpora, and the WASM build stay
green; XSLTNumberAnyTests pins document-order numbering across nesting.
This commit is contained in:
Mihaela Mihaljevic 2026-06-26 00:40:16 +02:00 committed by GitHub
parent 9d0d92bda3
commit 971a7e9f56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 56 additions and 14 deletions

View File

@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- `xsl:number level="any"` is no longer cubic over a wide fan-out. Numbering a node walks the nodes that precede it in document order, and that walk found each preceding sibling by rescanning the parent's whole child list, so visiting k preceding siblings cost O(k^2), and numbering n nodes was O(n^3). It now finds the context's index once and walks the children before it directly, so the walk is linear in the number of preceding nodes and the numbering is quadratic (inherent to counting preceding nodes per numbered node). The numbers are unchanged. Numbering 1000 items drops from about 4.7 s to about 0.7 s (~6x), and the scaling improves from cubic to quadratic. Guarded by `XSLTNumberAnyTests`.
### Fixed
- The `translate()` and `contains()`/`substring-before()`/`substring-after()` string functions are no longer quadratic. `translate()` scanned its `from` string for every source character (O(string * from)); it now builds the from-to map once and translates each character with an O(1) lookup. The substring functions matched the needle at every position of the haystack (O(n*m), quadratic and a denial-of-service vector on a repetitive untrusted string); they now use a linear-time Knuth-Morris-Pratt search. The results are unchanged (first-occurrence-wins translation, first-occurrence substring match). A large-alphabet `translate()` and a repetitive `contains()` each drop from hundreds of milliseconds to under a millisecond at 4k-16k characters. Guarded by `TranslateFunctionTests` and `SubstringSearchTests`.
## [0.4.5] - 2026-06-25

View File

@ -72,10 +72,16 @@ enum XSLTNumbering {
var current: Tree? = node
while let candidate = current {
if !visit(candidate) { return }
var sibling = precedingSibling(of: candidate)
while let visited = sibling {
if !visitSubtreeReversed(visited, visit) { return }
sibling = precedingSibling(of: visited)
if let parent = candidate.parent {
// The preceding siblings, nearest first. Find the candidate's
// index in the child list once, then walk the children before it
// directly, rather than rescanning the whole list for each
// preceding sibling (which made `xsl:number level="any"` cubic
// over a wide fan-out).
let index = parent.children.firstIndex { $0 === candidate } ?? 0
for visited in parent.children[..<index].reversed() where !visitSubtreeReversed(visited, visit) {
return
}
}
current = candidate.parent
}
@ -88,16 +94,6 @@ enum XSLTNumbering {
return visit(node)
}
private static func precedingSibling(of node: Tree) -> Tree? {
guard let parent = node.parent else { return nil }
var previous: Tree?
for sibling in parent.children {
if sibling === node { return previous }
previous = sibling
}
return nil
}
// MARK: Format tokens
/// Renders `numbers` with the 7.7.1 format engine. An empty list (a

View File

@ -0,0 +1,42 @@
import Testing
@testable import PureXML
/// `xsl:number level="any"` numbers a node by the count of matching nodes that
/// precede it (in document order, ancestors included) plus one. The walk over
/// preceding nodes now visits each preceding sibling's subtree directly instead
/// of rescanning the child list per sibling. These pin that the numbering is
/// unchanged: sequential in document order across nesting, counting only the
/// nodes the `count` pattern matches.
@Suite("xsl:number level=any")
struct XSLTNumberAnyTests {
private func numbers(_ source: String, count: String = "item") throws -> [String] {
let style = """
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="no"/>
<xsl:template match="/"><out><xsl:apply-templates select="//item"/></out></xsl:template>
<xsl:template match="item"><n><xsl:number level="any" count="\(count)"/></n></xsl:template>
</xsl:stylesheet>
"""
let out = try PureXML.XSLT.transform(stylesheet: style, source: source)
// Extract the digits between <n>...</n>.
return out.components(separatedBy: "<n>").dropFirst().map { String($0.prefix(while: { $0 != "<" })) }
}
@Test("items are numbered sequentially in document order across nesting")
func test_sequential() throws {
let source = "<doc><item/><g><item/><item/></g><item/></doc>"
#expect(try numbers(source) == ["1", "2", "3", "4"])
}
@Test("only nodes the count pattern matches are counted")
func test_countPattern() throws {
// <other/> elements are interleaved but not counted; items still 1..3.
let source = "<doc><other/><item/><other/><g><other/><item/></g><item/></doc>"
#expect(try numbers(source) == ["1", "2", "3"])
}
@Test("a single item is number one")
func test_single() throws {
#expect(try numbers("<doc><g><h><item/></h></g></doc>") == ["1"])
}
}