perf(xpointer): linear-time string-range() search (#314)

xpointer(string-range(...)) matched the search string at every position
of each node's value and re-allocated the whole needle window per
position, an O(text * needle) search and a denial-of-service vector on a
long node value with a near-matching needle. Replace it with a
linear-time Knuth-Morris-Pratt search (failure function built once, since
the needle is fixed across nodes). Matches are unchanged: same
non-overlapping, left-to-right occurrences, same offset/length.

A near-matching needle over a 16000-character node drops from about
0.18 s to about 0.002 s (~100x), now linear.
This commit is contained in:
Mihaela Mihaljevic 2026-06-26 04:01:41 +02:00 committed by GitHub
parent fab8008b42
commit 904881b81c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 72 additions and 9 deletions

View File

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- `xpointer(string-range(...))` no longer uses a quadratic substring search. It matched the search string at every position of each node's value and re-allocated the whole needle-width window per position (O(text * needle), a denial-of-service vector on a long node value with a near-matching needle); it now uses a linear-time Knuth-Morris-Pratt search with the failure function built once. The matches are unchanged: same non-overlapping, left-to-right occurrences, same offset/length. A near-matching needle over a 16000-character node drops from about 0.18 s to about 0.002 s (~100x), and the cost is now linear. Guarded by the new non-overlapping and backtracking cases in `XPointerRangeTests`.
## [0.4.7] - 2026-06-26
### Fixed

View File

@ -154,24 +154,62 @@ extension PureXML.XPointer.RangeForm {
private static func stringRanges(in nodes: [PureXML.Model.TreeNode], search: String, offset: Int?, length: Int?) -> [PureXML.XPointer.Range] {
guard !search.isEmpty else { return [] }
let needle = Array(search)
// The needle is the same for every node, so its KMP failure function is
// built once. The former scan re-tested (and re-allocated) the whole
// needle window at every position, an O(text * needle) search and a
// denial-of-service vector on a long node value with a near-matching
// needle; the search is now O(text + needle).
let failure = kmpFailure(needle)
return nodes.flatMap { node -> [PureXML.XPointer.Range] in
let characters = Array(node.stringValue)
var ranges: [PureXML.XPointer.Range] = []
var index = 0
while index + needle.count <= characters.count {
guard Array(characters[index ..< index + needle.count]) == needle else { index += 1
continue
}
return nonOverlappingMatches(of: needle, failure: failure, in: characters).map { index in
let begin = min(index + (offset ?? 1) - 1, characters.count)
let end = min(begin + (length ?? needle.count), characters.count)
let text = String(characters[max(begin, 0) ..< max(end, begin)])
ranges.append(PureXML.XPointer.Range(nodes: [.text(text)], text: text))
index += needle.count
return PureXML.XPointer.Range(nodes: [.text(text)], text: text)
}
return ranges
}
}
/// The KMP failure function (for each prefix, the length of its longest
/// proper prefix that is also a suffix) of `needle`.
private static func kmpFailure(_ needle: [Character]) -> [Int] {
var failure = [Int](repeating: 0, count: needle.count)
var matched = 0
for index in 1 ..< needle.count {
while matched > 0, needle[index] != needle[matched] {
matched = failure[matched - 1]
}
if needle[index] == needle[matched] { matched += 1 }
failure[index] = matched
}
return failure
}
/// The start indices of `needle` in `haystack`, left to right and
/// non-overlapping: each match resumes the scan past its end, matching the
/// former naive scan's `index += needle.count` advance, in O(text + needle).
private static func nonOverlappingMatches(of needle: [Character], failure: [Int], in haystack: [Character]) -> [Int] {
var matches: [Int] = []
var matched = 0
var index = 0
while index < haystack.count {
if haystack[index] == needle[matched] {
index += 1
matched += 1
if matched == needle.count {
matches.append(index - needle.count)
matched = 0
}
} else if matched > 0 {
matched = failure[matched - 1]
} else {
index += 1
}
}
return matches
}
/// The whole nodes spanned from `from` to `target`: the sibling run between
/// them when they share a parent, otherwise the two boundary nodes in document
/// order.

View File

@ -48,6 +48,27 @@ struct XPointerRangeTests {
#expect(result.allSatisfy { $0.text == "ir" })
}
@Test("string-range() matches are non-overlapping")
func test_stringRangeNonOverlapping() throws {
// "aa" in "aaaaa": the scan emits a match then resumes past it, so it
// finds positions 0 and 2 (not the overlapping 1 and 3). The linear KMP
// search must keep that advance-by-needle behavior.
let xml = try PureXML.parse("<d>aaaaa</d>")
let result = try PureXML.XPointer.evaluateRanges("xpointer(string-range(/d, \"aa\"))", over: xml)
#expect(result.count == 2)
#expect(result.allSatisfy { $0.text == "aa" })
}
@Test("string-range() finds a match that needs failure-function backtracking")
func test_stringRangeBacktrack() throws {
// In "abcabcabd" the needle "abcabd" matches only at index 3; reaching it
// requires backtracking after the partial "abcab" at index 0 mismatches.
let xml = try PureXML.parse("<d>abcabcabd</d>")
let result = try PureXML.XPointer.evaluateRanges("xpointer(string-range(/d, \"abcabd\"))", over: xml)
#expect(result.count == 1)
#expect(result.first?.text == "abcabd")
}
@Test("string-range() honors offset and length")
func test_stringRangeOffsetLength() throws {
// From the "ir" match in "first", start at offset 2 (the 'r') for length 1.