diff --git a/CHANGELOG.md b/CHANGELOG.md index 351dd42..fc68b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/XSLT/XSLTNumbering.swift b/Sources/XSLT/XSLTNumbering.swift index 5e012f9..e2c4b7d 100644 --- a/Sources/XSLT/XSLTNumbering.swift +++ b/Sources/XSLT/XSLTNumbering.swift @@ -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[.. 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 diff --git a/Tests/XSLTNumberAnyTests.swift b/Tests/XSLTNumberAnyTests.swift new file mode 100644 index 0000000..bf27c00 --- /dev/null +++ b/Tests/XSLTNumberAnyTests.swift @@ -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 = """ + + + + + + """ + let out = try PureXML.XSLT.transform(stylesheet: style, source: source) + // Extract the digits between .... + return out.components(separatedBy: "").dropFirst().map { String($0.prefix(while: { $0 != "<" })) } + } + + @Test("items are numbered sequentially in document order across nesting") + func test_sequential() throws { + let source = "" + #expect(try numbers(source) == ["1", "2", "3", "4"]) + } + + @Test("only nodes the count pattern matches are counted") + func test_countPattern() throws { + // elements are interleaved but not counted; items still 1..3. + let source = "" + #expect(try numbers(source) == ["1", "2", "3"]) + } + + @Test("a single item is number one") + func test_single() throws { + #expect(try numbers("") == ["1"]) + } +}