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.