From 904881b81cfd9efb605a749db86e8400862d328e Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 26 Jun 2026 04:01:41 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 4 +++ Sources/XPointer/PointerRange.swift | 56 ++++++++++++++++++++++++----- Tests/XPointerRangeTests.swift | 21 +++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f837869..eaa3930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/XPointer/PointerRange.swift b/Sources/XPointer/PointerRange.swift index 0752f25..c22fa47 100644 --- a/Sources/XPointer/PointerRange.swift +++ b/Sources/XPointer/PointerRange.swift @@ -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. diff --git a/Tests/XPointerRangeTests.swift b/Tests/XPointerRangeTests.swift index c3ccea8..ca276f0 100644 --- a/Tests/XPointerRangeTests.swift +++ b/Tests/XPointerRangeTests.swift @@ -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("aaaaa") + 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("abcabcabd") + 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.