Skip to main content

scrive_core/
find.rs

1//! Find — the search core.
2//!
3//! Search is a **literal substring** with a case-fold toggle, or — behind the
4//! whole-word / regex options — a **line-scoped** pattern. (The private
5//! `Matcher` type carries the full rationale for why a line, and not a byte
6//! window, is the unit there.) Case folding is Unicode **simple** folding in
7//! every mode (`é`/`É`, `σ`/`Σ`/`ς`; never the multi-char `ß`/`ss`), so
8//! toggling an option never changes what folds. The search *bar* is app-side
9//! chrome; this module owns only the match model, the scan, and what a
10//! replacement means.
11//!
12//! The one design choice: the match set **lives only in the decoration store**
13//! — the sorted `FindMatch` decorations ARE the match set, in document order
14//! (one fact, one owner). [`FindState`] holds the query and the scan
15//! bookkeeping, never a shadow list of handles. The set is kept current
16//! **eagerly**: every committed transaction (forward edit, undo, redo — they
17//! share the same view-rebase path) runs [`FindState::on_commit`], a windowed
18//! repair around each edit, so consumers never see a stale match. The one
19//! remaining debounced path is the capped-set refill
20//! ([`FindState::maybe_rescan`]).
21//!
22//! Documented, test-pinned relaxation: for needles that cannot overlap
23//! themselves the repaired set is byte-identical to a fresh [`scan`]; a
24//! self-overlapping needle (`"aa"`) repairs to a *maximal valid*
25//! non-overlapping set whose phase near the edit may differ from the greedy
26//! full scan until the next query change.
27
28use core::ops::Range;
29
30use crate::buffer::Buffer;
31use crate::coords::{Bias, Point};
32use crate::decorations::{
33    DecorationId, DecorationKind, DecorationStore, Stickiness, TrackedRange,
34};
35use crate::patch::Patch;
36
37/// An active find query: the pattern plus its option flags.
38///
39/// `PartialEq` so the `Document` surface can no-op an unchanged query without
40/// re-scanning — the flags are part of that identity, because each one changes
41/// *what matches*, not how it is displayed.
42///
43/// The flags pick the matcher: plain text takes the literal byte scan;
44/// `whole_word` or `regex` take the line-scoped engine.
45///
46/// `#[non_exhaustive]` so a future option can be added without a breaking
47/// change: outside this crate, build one with [`FindQuery::new`] (a plain
48/// literal query) and set the options you want on its public fields, rather
49/// than a struct literal.
50#[derive(Clone, Default, PartialEq, Eq, Debug)]
51#[non_exhaustive]
52pub struct FindQuery {
53    /// The text to find — a literal, or a regular expression when
54    /// [`regex`](Self::regex) is set. Empty means "no matches" (never
55    /// match-all).
56    pub text: String,
57    /// When `false`, case variants fold together — Unicode **simple** case
58    /// folding (one char to one char: `a`/`A`, `é`/`É`, `σ`/`Σ`/`ς`, `k`/`K`
59    /// KELVIN SIGN; never full folding's `ß`/`ss`), the SAME in the literal,
60    /// whole-word and regex modes, so toggling them never changes what folds.
61    pub case_sensitive: bool,
62    /// Match only whole words — the `ab|` option. Each side of a match is judged
63    /// by the match's own edge character there (VS Code's rule): a side whose
64    /// edge is a **word** character must meet a non-word neighbour or the edge
65    /// of its line; a side whose edge is a **separator** holds whatever its
66    /// neighbour is. So `foo` never matches inside `foobar`, while `(foo`
67    /// matches in ` (foo) ` and `==` in `a == b`. "Word character" is the
68    /// crate's one word rule — alphanumeric or `_`, the same one word motion
69    /// uses. In regex mode the edges are the characters each match actually
70    /// covered, not the pattern's spelling (`\(foo` behaves like `(foo`).
71    pub whole_word: bool,
72    /// Treat [`text`](Self::text) as a regular expression — the `.*` option.
73    ///
74    /// Matching is **line-scoped**: the engine is fed one line at a time, so a
75    /// pattern can never match across a newline. An invalid
76    /// pattern is not an error state — it is what every prefix of a pattern
77    /// being typed looks like — so it simply yields no matches, and
78    /// [`FindState::pattern_error`] carries the reason for the bar to show.
79    pub regex: bool,
80}
81
82impl FindQuery {
83    /// A plain literal query for `text` — every option off (case-insensitive,
84    /// not whole-word, not regex). The common case; set the public option fields
85    /// on the returned value to change it.
86    #[must_use]
87    pub fn new(text: impl Into<String>) -> Self {
88        Self { text: text.into(), ..Self::default() }
89    }
90}
91
92// Op-count canary: `Matcher::compile` calls on this thread. A query compiles
93// ONCE, at `FindState::set_query`; a test asserts the replace paths read that
94// cached matcher rather than compiling the pattern again per press.
95// Thread-local so parallel tests don't race the count. Debug/test only;
96// zero-cost in release.
97#[cfg(any(test, debug_assertions))]
98thread_local! {
99    pub(crate) static MATCHER_COMPILES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
100}
101
102/// The compiled form of a [`FindQuery`] — the one place that decides *how* a
103/// query matches, so every scan path shares the decision.
104///
105/// # Why two matchers, and why the second is line-scoped
106///
107/// [`FindState::on_commit`]'s windowed repair — the thing that keeps find at
108/// O(edit window) instead of O(document) per keystroke — is built on `k`, a
109/// bound on the needle's match length: a match can start at most `k−1` bytes
110/// left of a changed byte, so that is how far the influence window has to
111/// reach. An **unbounded** pattern destroys that argument outright (`.*` lets
112/// an edit anywhere create a match starting at column 0), and there is no `k`
113/// to widen by.
114///
115/// The fix is to bound matches by something the repair *can* window on: a line.
116/// [`Lines`](Self::Lines) feeds the engine **one line at a time**, so `\n` is
117/// never in the haystack and a pattern therefore *cannot* match across one —
118/// structurally, not by convention (statically deciding whether `\s` or `[^x]`
119/// can match `\n` is fragile). An edit then only creates or destroys matches on
120/// the lines it touches, so the influence window is those lines. Windowed again,
121/// and simpler than the `k−1` dance.
122///
123/// Whole-word rides the same path for a different reason: judging a word
124/// boundary needs the character *outside* the match, which a byte window's
125/// slice does not contain — but a line always holds its own matches' context,
126/// because a word can never span a newline. So `whole_word` is line-scoped too,
127/// and its edge rule lives in exactly one place, [`LineMatcher`], which every
128/// line-scoped scan path iterates through.
129///
130/// Plain literal text keeps the byte-window path: it is the common case, it is
131/// what the windowed scan and the `k−1` repair were built for, and nothing
132/// about it needs a line. (Case folding can make a literal match's byte length
133/// differ from the needle's, but only within a bound the needle fixes — see
134/// [`LiteralMatcher`] — and the `k−1` argument only ever needed a bound.)
135#[derive(Debug)]
136enum Matcher {
137    /// Plain literal text — the byte-window scan, bounded `k`, `k−1` repair.
138    Literal(LiteralMatcher),
139    /// Whole-word or regex — the line-scoped engine (see the type docs).
140    Lines(LineMatcher),
141}
142
143impl Matcher {
144    /// Compile `query`, or return the pattern error to show the user.
145    ///
146    /// A syntax error is a NORMAL state, not a failure: every prefix of a
147    /// pattern being typed (`(`, `[a-`) is invalid, so callers surface the
148    /// message and match nothing rather than treating it as broken.
149    fn compile(query: &FindQuery) -> Result<Self, String> {
150        #[cfg(any(test, debug_assertions))]
151        MATCHER_COMPILES.with(|c| c.set(c.get() + 1));
152        if !query.whole_word && !query.regex {
153            return LiteralMatcher::new(query).map(Self::Literal);
154        }
155        query_regex(query).map(|re| {
156            Self::Lines(LineMatcher { re, whole_word: query.whole_word, expands_groups: query.regex })
157        })
158    }
159
160    /// Whether this matcher is bounded to single lines — the flag every scan and
161    /// repair path branches on.
162    fn is_line_scoped(&self) -> bool {
163        matches!(self, Self::Lines(_))
164    }
165}
166
167/// THE regex compile of a query's text — the one owner of how a pattern
168/// becomes an engine, shared by the line-scoped matcher and the folding
169/// literal (which matches through its own escape, see [`LiteralMatcher`]).
170///
171/// The pattern is NEVER wrapped for whole-word (see [`LineMatcher`] for why
172/// `\b…\b` is wrong): a literal is just its escaped text, a regex the user's
173/// pattern verbatim. A whole-word literal is therefore the whole-word regex of
174/// its own escape — `(foo` and `\(foo` compile to the same engine and the same
175/// edge check, so they cannot disagree. Case folding is the query's flag, in
176/// every mode, so toggling an option never changes what folds.
177fn query_regex(query: &FindQuery) -> Result<regex::Regex, String> {
178    let pattern = if query.regex {
179        std::borrow::Cow::Borrowed(query.text.as_str())
180    } else {
181        std::borrow::Cow::Owned(regex::escape(&query.text))
182    };
183    regex::RegexBuilder::new(&pattern)
184        .case_insensitive(!query.case_sensitive)
185        // The line engine's haystack is one line, so `.` never sees a `\n`
186        // anyway; this just makes `$`/`^` mean the line's edges, which is what a
187        // line-scoped find should mean. (An escaped literal has no anchors, so
188        // it changes nothing there.)
189        .multi_line(true)
190        .build()
191        .map_err(|e| e.to_string())
192}
193
194/// The plain-literal engine: the needle, how it folds case, and the byte-length
195/// bounds of its matches — the ONE owner of "where does this literal match", so
196/// the display scan, the windowed buffer scan, the per-edit repair and
197/// replace-all cannot fork on it.
198///
199/// # Why case folding goes through the regex crate
200///
201/// Case-insensitive literal search must fold EXACTLY as regex mode does, or
202/// toggling `.*` on an escaped-equivalent query changes what matches (`é`
203/// finding `É` only with the regex option on). The regex crate's `(?i)` —
204/// Unicode **simple** case folding — is the owner of that rule, so a folding
205/// literal is matched by the regex of its own escape, compiled `(?i)`: the two
206/// modes then agree by construction, not by a second hand-kept fold table.
207/// Simple folding maps one char to one char — never `ß` → `ss` (that is full
208/// folding, out of scope in both modes).
209///
210/// # Why a match length is a *range*
211///
212/// Simple folding is one char ↔ one char, but not one byte length ↔ the same
213/// length: `k` (1 byte) folds with `K` KELVIN SIGN (3), `s` (1) with `ſ` LONG
214/// S (2), `ß` (2) with `ẞ` (3). So a folding literal's matches are exactly as
215/// many CHARS as the needle, but not as many bytes. The windowed paths never
216/// needed the exact length, only bounds:
217/// [`max_len`](Self::max_len) for how far left of a changed byte a match can
218/// start (the `k−1` overlap/repair margin), [`min_len`](Self::min_len) for
219/// whether a match can still fit in what is left of a scan.
220#[derive(Debug)]
221struct LiteralMatcher {
222    /// The needle as typed — the case-sensitive path's `memmem` needle.
223    needle: String,
224    /// Case-insensitive only: the needle's escape compiled `(?i)` (see the
225    /// type docs). `None` ⇒ exact byte matching.
226    fold: Option<regex::Regex>,
227    /// No match is shorter than this many bytes. Case-sensitive: the needle's
228    /// length. Folding: its CHAR count (a match is that many chars, each at
229    /// least one byte).
230    min_len: u32,
231    /// No match is longer than this many bytes — the `k` of the windowed
232    /// scan's seam overlap and the repair's `k−1` margins. Case-sensitive: the
233    /// needle's length. Folding: the sum of each needle char's
234    /// [`fold_width`](Self::fold_width).
235    max_len: u32,
236}
237
238impl LiteralMatcher {
239    /// Compile `query`'s literal. Exact matching cannot fail; a folding needle
240    /// so large that its `(?i)` escape exceeds the regex crate's size limit
241    /// reports that limit, exactly as the whole-word path (which compiles the
242    /// same escape) always has.
243    fn new(query: &FindQuery) -> Result<Self, String> {
244        let needle = query.text.clone();
245        let len = needle.len() as u32;
246        if query.case_sensitive {
247            return Ok(Self { needle, fold: None, min_len: len, max_len: len });
248        }
249        // The literal's escape, compiled `(?i)` by the one owner.
250        let fold = query_regex(query)?;
251        let min_len = needle.chars().count() as u32;
252        let max_len = needle.chars().map(Self::fold_width).fold(0u32, u32::saturating_add);
253        Ok(Self { needle, fold: Some(fold), min_len, max_len })
254    }
255
256    /// The widest UTF-8 encoding (bytes) of any char that simple-folds with
257    /// `c` — one needle char's share of [`max_len`](Self::max_len).
258    ///
259    /// Exact for ASCII, the common needle and the one where a loose bound costs
260    /// most: the repair window is `±(max_len−1)`, so a 4-bytes-per-char bound
261    /// made a dense ASCII needle's keystroke repair tear out and re-insert
262    /// several times the matches it needed to (measured: +50% per keystroke on
263    /// the find bench's corpus). Only two non-ASCII chars simple-fold into
264    /// ASCII — `K` KELVIN SIGN (3 bytes, with `k`) and `ſ` LONG S (2, with
265    /// `s`) — a fact taken exhaustively from the regex crate itself, which owns
266    /// folding, by `fold_width_covers_every_fold_partner_of_ascii`. Beyond
267    /// ASCII the bound is simply 4, the widest any char can be: that needs no
268    /// Unicode table to be right, and it widens a window by a constant factor
269    /// of the NEEDLE, never by anything the document scales.
270    fn fold_width(c: char) -> u32 {
271        match c {
272            'k' | 'K' => 3,
273            's' | 'S' => 2,
274            c if c.is_ascii() => 1,
275            _ => 4,
276        }
277    }
278
279    /// Every non-overlapping match in `text`, leftmost-first, as byte spans
280    /// relative to it; the `bool` is whether `cap` was hit. An empty needle
281    /// matches nothing (never match-all).
282    ///
283    /// A literal has no context: whether it matches at a start depends only on
284    /// the bytes it covers, so scanning a char-boundary SLICE finds exactly the
285    /// whole text's matches that fit in it — what lets the windowed paths feed
286    /// it windows.
287    fn scan(&self, text: &str, cap: usize) -> (Vec<Range<u32>>, bool) {
288        if self.needle.is_empty() {
289            return (Vec::new(), false); // empty needle = zero matches
290        }
291        let (mut exact, mut folded);
292        let hits: &mut dyn Iterator<Item = Range<usize>> = match &self.fold {
293            // memmem yields non-overlapping occurrences leftmost-first — the
294            // next probe starts at the previous match's end.
295            None => {
296                let k = self.needle.len();
297                exact = memchr::memmem::find_iter(text.as_bytes(), self.needle.as_bytes())
298                    .map(move |i| i..i + k);
299                &mut exact
300            }
301            // The regex crate's own leftmost-first, non-overlapping iteration.
302            // The pattern is a run of single-char classes, so at most one match
303            // can begin at any start — this IS try-every-start greedy.
304            Some(re) => {
305                folded = re.find_iter(text).map(|m| m.range());
306                &mut folded
307            }
308        };
309        let mut spans = Vec::new();
310        for r in hits {
311            if spans.len() == cap {
312                return (spans, true); // cap reached
313            }
314            // The bounds every windowed path's correctness rests on.
315            debug_assert!(
316                (self.min_len..=self.max_len).contains(&(r.len() as u32)),
317                "a literal match of {} bytes escaped its bounds {}..={}",
318                r.len(),
319                self.min_len,
320                self.max_len
321            );
322            spans.push(r.start as u32..r.end as u32);
323        }
324        (spans, false)
325    }
326}
327
328/// The line-scoped engine: a compiled pattern plus the whole-word edge rule —
329/// the ONE owner of "which spans of a line match", so the display scan, the
330/// per-edit repair, and replace-all cannot fork on it.
331///
332/// # Why whole-word is a per-match edge check, not `\b…\b`
333///
334/// `\b` asserts that the characters on its two sides differ in wordness, so
335/// wrapping a needle whose edge is punctuation inverts the rule on that side:
336/// `\b(foo` demands a WORD character before the `(` — it can never match in
337/// ` (foo) ` — and `foo.\b` matches only when a word character follows the `.`.
338/// The rule users mean (VS Code's) is about the match's *own* edge: a side
339/// whose edge character is a word character needs a non-word neighbour (or the
340/// line edge); a side whose edge is a separator is free. That depends on what
341/// a match actually covered, which for a regex (`\(?foo`, `foo|==`) differs
342/// match by match and is unknowable at compile time — so the check runs on
343/// each match against its line, after the engine finds it.
344///
345/// The word rule is [`crate::movement::is_word_char`], the crate's single
346/// owner, so whole-word find agrees with word motion and Ctrl+D about where a
347/// word ends.
348#[derive(Debug)]
349struct LineMatcher {
350    /// The pattern: the user's regex verbatim, or a literal's escape. Never
351    /// wrapped — the whole-word rule is [`whole_word`](Self::whole_word)'s job.
352    re: regex::Regex,
353    /// Apply the edge check to each match (see the type docs).
354    whole_word: bool,
355    /// Whether a replacement expands `$1`-style group references: `re` is the
356    /// user's regex, so its capture groups are the user's, rather than a
357    /// whole-word literal's escape, whose replacement is plain text (see
358    /// [`replacements`]).
359    expands_groups: bool,
360}
361
362impl LineMatcher {
363    /// Every match in `line` — ONE line, without its `\n` — leftmost-first and
364    /// non-overlapping, as byte spans relative to `line`. Zero-width hits are
365    /// dropped (nothing to navigate to, highlight, or replace).
366    fn find_iter<'a>(&'a self, line: &'a str) -> LineMatches<'a> {
367        LineMatches { m: self, line, at: 0 }
368    }
369
370    /// Whether the span `s..e` (non-empty, on char boundaries) of `line` passes
371    /// the whole-word edge rule on both sides.
372    fn is_whole_word(line: &str, s: usize, e: usize) -> bool {
373        use crate::movement::is_word_char;
374        // A side fails only when its edge char AND its neighbour are both word
375        // characters; a line edge (`None`) is never a word character.
376        let holds = |edge: Option<char>, neighbour: Option<char>| {
377            !(edge.is_some_and(is_word_char) && neighbour.is_some_and(is_word_char))
378        };
379        holds(line[s..].chars().next(), line[..s].chars().next_back())
380            && holds(line[..e].chars().next_back(), line[e..].chars().next())
381    }
382}
383
384/// [`LineMatcher::find_iter`]'s iterator.
385///
386/// The cursor rule is what makes the set "the leftmost non-overlapping VALID
387/// placements": an accepted match resumes at its end (non-overlapping), while a
388/// REJECTED one — zero-width, or failing the edge check — resumes one char past
389/// its *start*, never its end, so it cannot hide a valid match that starts
390/// inside it (`a-a` in `xa-a-a`: `1..4` fails against `x`, `3..6` is bounded by
391/// `-`). For a literal needle that is exactly try-every-start, so on needles
392/// with word-character edges it keeps the old `\b…\b` results (up to the word
393/// rule itself: `\b`'s Unicode `\w` also admitted marks and connector
394/// punctuation; this is now the crate's own rule, as above). Without the
395/// edge check (plain regex) it is byte-identical to `Regex::find_iter` with
396/// empties dropped (pinned by `line_matches_without_whole_word_equal_find_iter`).
397///
398/// One limit, inherent to judging a match after the engine picks it: the engine
399/// offers ONE match per start (leftmost-first), so a rejected match's start is
400/// not retried with a different length (`foo|foobar` rejects `foo` in `foobar`
401/// rather than taking `foobar`). Cost residual: each rejection re-searches from
402/// one char further on, so a pathological whole-word regex whose long matches
403/// are all rejected (`.*a` on a line of `xa`s) is quadratic in that LINE's
404/// length — never in the document's, since the scan is line-scoped.
405struct LineMatches<'a> {
406    m: &'a LineMatcher,
407    line: &'a str,
408    /// The next search start (a char boundary of `line`).
409    at: usize,
410}
411
412impl Iterator for LineMatches<'_> {
413    type Item = Range<usize>;
414
415    fn next(&mut self) -> Option<Range<usize>> {
416        while self.at <= self.line.len() {
417            // `find_at`, not a slice: the engine sees the text before `at`, so
418            // the user's own `^` / `\b` keep their line context on a retry.
419            let hit = self.m.re.find_at(self.line, self.at)?;
420            let (s, e) = (hit.start(), hit.end());
421            if s < e && (!self.m.whole_word || LineMatcher::is_whole_word(self.line, s, e)) {
422                self.at = e;
423                return Some(s..e);
424            }
425            // Rejected: retry one char past the START (see the type docs). At the
426            // line's end there is no next char, which ends the loop.
427            self.at = s + self.line[s..].chars().next().map_or(1, char::len_utf8);
428        }
429        None
430    }
431}
432
433/// Debounce window (ms) for the capped-set refill — a user-facing default,
434/// exported as a `pub fn`. This is the ONLY debounced find path: ordinary edits
435/// repair the match set eagerly at the commit.
436#[must_use]
437pub fn default_find_debounce() -> u64 {
438    100
439}
440
441/// The scan stops after this many matches and records `capped` — cheap
442/// insurance against a pathological query. A capped set is a *prefix of the
443/// document*: everything up to [`FindState`]'s coverage frontier is exact,
444/// nothing beyond it is represented.
445pub const FIND_MATCH_CAP: usize = 10_000;
446
447/// Every non-overlapping match of `query` in `text`, leftmost-first, as byte
448/// spans; the `bool` is whether the scan hit [`FIND_MATCH_CAP`].
449///
450/// The query's options pick the matcher. Plain text takes a **literal
451/// substring** scan — the next probe starts at the previous match's end
452/// (non-overlapping), and a folded match may differ in byte length from the
453/// needle (`k` finds the 3-byte KELVIN SIGN). `whole_word` / `regex` take the
454/// **line-scoped** engine, fed one line at a time so a match never crosses a
455/// newline. Every mode folds case alike (Unicode simple folding). An empty
456/// literal query yields no matches (never match-all); an unfinished regex
457/// yields none until it parses.
458///
459/// The pure whole-text scan the tests use as the oracle. The live paths (the
460/// query-change full scan, the capped refill, the per-edit window repairs) share
461/// its matcher rather than calling it directly, so the match rules cannot fork.
462/// Case-sensitive literal search rides `memchr::memmem`; the fold path is the
463/// regex crate's `(?i)` over the needle's escape (see `LiteralMatcher`) — both
464/// pinned against the naive reference scan where its byte-wise ASCII fold is
465/// exact (`scan_equals_the_naive_oracle`), and the fold path against regex
466/// mode everywhere (`literal_and_regex_fold_case_identically`).
467#[must_use]
468pub fn scan(text: &str, query: &FindQuery) -> (Vec<Range<u32>>, bool) {
469    scan_capped(text, query, FIND_MATCH_CAP)
470}
471
472/// [`scan`] with the cap injected. Compiles the query's matcher, then defers to
473/// [`scan_with`] — the same per-slice matcher the windowed paths use — so the
474/// text you replace cannot diverge from the text you were shown.
475fn scan_capped(text: &str, query: &FindQuery, cap: usize) -> (Vec<Range<u32>>, bool) {
476    match Matcher::compile(query) {
477        Ok(m) => scan_with(text, &m, cap),
478        // An unfinished pattern (`(`, `[a-`) matches nothing. Not an error: it
479        // is what every prefix of a pattern being typed looks like.
480        Err(_) => (Vec::new(), false),
481    }
482}
483
484/// [`scan_capped`] against an ALREADY-compiled matcher, so a windowed scan
485/// compiles the pattern once rather than once per window.
486fn scan_with(text: &str, m: &Matcher, cap: usize) -> (Vec<Range<u32>>, bool) {
487    match m {
488        Matcher::Literal(lit) => lit.scan(text, cap),
489        Matcher::Lines(lm) => scan_lines(text, lm, cap),
490    }
491}
492
493/// The line-scoped scan: the engine is fed one line at a time — **without its
494/// newline** — so a pattern can never match across one. Offsets are relative to
495/// `text`.
496fn scan_lines(text: &str, lm: &LineMatcher, cap: usize) -> (Vec<Range<u32>>, bool) {
497    let mut spans: Vec<Range<u32>> = Vec::new();
498    let mut base = 0usize;
499    for line in text.split_inclusive('\n') {
500        // Strip the `\n` before matching: keeping it in the haystack is exactly
501        // what would let `\s` or `[^x]` slip across a line and break the
502        // line-windowed repair this design rests on.
503        let body = line.strip_suffix('\n').unwrap_or(line);
504        // Zero-width hits (`a*` against "bbb") never come out of `find_iter`:
505        // there is nothing to navigate to, highlight, or replace, and the
506        // store's `EmptyPolicy::Drop` would discard them anyway — so the count
507        // stays honest about what the user can act on.
508        for m in lm.find_iter(body) {
509            if spans.len() == cap {
510                return (spans, true); // cap reached
511            }
512            spans.push((base + m.start) as u32..(base + m.end) as u32);
513        }
514        base += line.len();
515    }
516    (spans, false)
517}
518
519/// [`scan`] driven through the buffer's backing-agnostic ranged reads:
520/// fixed-size windows with a longest-match−1 overlap, so a match straddling a
521/// window boundary is found by the next window and the result is
522/// **byte-identical to `scan(&buffer.text(), query)`** (pinned by
523/// `windowed_scan_equals_the_whole_text_scan`). Never materializes the
524/// document — peak transient is one window — and a capped dense query stops
525/// after O(bytes-until-cap), which `buffer.text()` would defeat by copying
526/// the whole rope up front.
527///
528/// Takes the ALREADY-compiled matcher: [`FindState`] scans through the one it
529/// cached at [`set_query`](FindState::set_query), so a query change compiles
530/// its pattern once, not once per scan.
531fn scan_buffer(
532    buffer: &Buffer,
533    m: &Matcher,
534    window: u32,
535    within: Range<u32>,
536) -> (Vec<Range<u32>>, bool) {
537    scan_buffer_capped(buffer, m, window, FIND_MATCH_CAP, within)
538}
539
540/// Every match of the compiled matcher `m` inside `within` — the whole document
541/// when find is unscoped, or the find-in-selection scope — **uncapped**. The
542/// replace-all scan.
543///
544/// Replace-all cannot read the live match set: that set is capped at
545/// [`FIND_MATCH_CAP`] and is only a *prefix of its range*, so "all" built from
546/// it would silently stop at the cap and leave the tail untouched. It runs the
547/// same windowed [`scan_buffer_capped`] the display scan does — one match rule,
548/// no fork — with the cap lifted. Whole-`within` in the worst case (you cannot
549/// replace what you have not found), which is why it is reachable only from a
550/// discrete user action, never a keystroke.
551fn scan_all(buffer: &Buffer, m: &Matcher, within: Range<u32>) -> Vec<Range<u32>> {
552    scan_buffer_capped(buffer, m, crate::buffer::SCAN_WINDOW, usize::MAX, within).0
553}
554
555/// [`scan_buffer`] with the cap injected — see [`scan_capped`].
556///
557/// `within` bounds the scan (the whole document, or the find-in-selection
558/// scope). It is clamped to the document and snapped INWARD to char boundaries,
559/// so a caller-supplied scope can be any offsets without breaking `slice`'s
560/// str contract.
561fn scan_buffer_capped(
562    buffer: &Buffer,
563    m: &Matcher,
564    window: u32,
565    cap: usize,
566    within: Range<u32>,
567) -> (Vec<Range<u32>>, bool) {
568    // Snap INWARD: `hi` left, `lo` right, so the scanned span never grows past
569    // the requested bound and never splits a char.
570    let hi = buffer.clip_offset(within.end.min(buffer.len()), Bias::Left);
571    let lo = buffer.clip_offset(within.start.min(hi), Bias::Right);
572    let lit = match m {
573        Matcher::Lines(lm) => return scan_buffer_lines(buffer, lm, cap, lo..hi),
574        Matcher::Literal(lit) => lit,
575    };
576    // A folding match's byte length is a range, not the needle's length (see
577    // `LiteralMatcher`): the seam overlap must cover the LONGEST possible
578    // match, while "can a match still fit" asks about the SHORTEST.
579    let (k, k_min) = (u64::from(lit.max_len), u64::from(lit.min_len));
580    if k == 0 {
581        return (Vec::new(), false); // empty needle = zero matches
582    }
583    // ≥ 2k, so every iteration provably advances even for giant needles.
584    let window = u64::from(window).max(k * 2);
585    let mut spans: Vec<Range<u32>> = Vec::new();
586    let mut pos: u32 = lo; // the non-overlapping scan cursor (next probe start)
587    while u64::from(pos) + k_min <= u64::from(hi) {
588        // The window end snaps RIGHT to a char boundary: `slice` stays on the
589        // str-slicing contract, the window is never empty, and growing a
590        // window never loses a candidate.
591        let win_end = buffer
592            .clip_offset((u64::from(pos) + window).min(u64::from(hi)) as u32, Bias::Right);
593        let (local, local_capped) = lit.scan(&buffer.slice(pos..win_end), cap);
594        let found = !local.is_empty();
595        for r in local {
596            if spans.len() == cap {
597                return (spans, true); // cap reached
598            }
599            spans.push(pos + r.start..pos + r.end);
600        }
601        // A capped in-window scan left the window's tail unscanned — keep
602        // going from the last match even at the scan's end (the overflow
603        // match it found still has to trip the cap above).
604        if win_end == hi && !local_capped {
605            break;
606        }
607        // Resume through the one owner of the seam logic ([`Buffer::scan_resume`]):
608        // past the last match when the window found one (the non-overlap cursor —
609        // the window's tail may be re-scanned but is never skipped), else at the
610        // char boundary before `win_end − (k−1)`.
611        let last_end = found.then(|| spans.last().expect("found ⇒ spans non-empty").end);
612        pos = buffer.scan_resume(pos, win_end, k as u32, last_end);
613    }
614    (spans, false)
615}
616
617/// The line-scoped windowed scan over a buffer, in absolute offsets.
618///
619/// Whole lines, **no overlap**: a match cannot cross `\n`, so a line boundary
620/// can never fall inside one and the literal path's `k−1` seam logic has nothing
621/// to do here.
622///
623/// Boundaries are judged against the WHOLE line even when `within` opens or
624/// closes mid-line — a scope edge must never fake a word boundary — so matches
625/// are filtered to `within` afterwards rather than by truncating the haystack.
626fn scan_buffer_lines(
627    buffer: &Buffer,
628    lm: &LineMatcher,
629    cap: usize,
630    within: Range<u32>,
631) -> (Vec<Range<u32>>, bool) {
632    let mut spans: Vec<Range<u32>> = Vec::new();
633    if within.start >= within.end {
634        return (spans, false);
635    }
636    let first = buffer.offset_to_point(within.start).row;
637    let last = buffer.offset_to_point(within.end).row;
638    for row in first..=last {
639        let base = buffer.point_to_offset(Point::new(row, 0));
640        let line = buffer.line(row); // excludes the trailing `\n`
641        for m in lm.find_iter(&line) {
642            let (s, e) = (base + m.start as u32, base + m.end as u32);
643            if s < within.start || e > within.end {
644                continue; // outside the scope, which may open/close mid-line
645            }
646            if spans.len() == cap {
647                return (spans, true); // cap reached
648            }
649            spans.push(s..e);
650        }
651    }
652    (spans, false)
653}
654
655/// The whole lines a (post-edit) byte range touches — the line-scoped repair's
656/// influence window.
657///
658/// This is the line-scoped answer to the literal path's `±(k−1)`: a match cannot
659/// cross a `\n`, so an edit can only have created or destroyed matches on the
660/// lines it touched, and re-scanning exactly those lines is both necessary and
661/// sufficient.
662fn whole_lines(buffer: &Buffer, r: &Range<u32>) -> Range<u32> {
663    let first = buffer.offset_to_point(r.start).row;
664    let last = buffer.offset_to_point(r.end).row;
665    let start = buffer.point_to_offset(Point::new(first, 0));
666    // The last touched line's CONTENT end — deliberately NOT the next line's
667    // start. `take_matching_in` removes everything *touching* the window, so a
668    // window reaching the next line's first byte would carry off a match that
669    // starts there, while the re-scan (bounded by this same range) would refuse
670    // to re-add it as out of range — silently losing it. Ending before the `\n`
671    // makes the window unable to touch anything off its own lines.
672    let base = buffer.point_to_offset(Point::new(last, 0));
673    start..base + buffer.line(last).len() as u32
674}
675
676/// Every match in `within`, paired with the text that should replace it — the
677/// replace-all plan, and the one place that decides what a replacement *means*.
678///
679/// In **regex** mode `replacement` is a TEMPLATE: `$1` / `${name}` expand
680/// against each match's own capture groups, as VS Code does. In every other mode
681/// (literal, whole-word) it is literal text — a `$1` typed into the replace box
682/// replaces with a literal `$1`, because nothing captured anything.
683///
684/// When `preserve_case` is set (the replace bar's `AB` toggle), each
685/// replacement is re-cased to the pattern of the text it replaces, by VS Code's
686/// rule — an ALL-CAPS match takes an upper-cased replacement, a Capitalized one
687/// a capitalized one, a camelCase one a lower first letter, and `-`/`_`
688/// segments are re-cased one by one — so a mechanical rename does not flatten
689/// the casing of the identifiers it touches. See [`preserve_case_of`].
690///
691/// Takes the ALREADY-compiled matcher — the one [`FindState`] cached at
692/// [`set_query`](FindState::set_query) (reached through
693/// [`FindState::replacements`]) — so a replace press compiles nothing.
694fn replacements(
695    buffer: &Buffer,
696    m: &Matcher,
697    within: Range<u32>,
698    replacement: &str,
699    preserve_case: bool,
700) -> Vec<(Range<u32>, String)> {
701    let lm = match m {
702        Matcher::Lines(lm) if lm.expands_groups => lm,
703        // Literal and whole-word: one fixed string, every match — re-cased to
704        // the match when `preserve_case` is on.
705        _ => {
706            return scan_all(buffer, m, within)
707                .into_iter()
708                .map(|r| {
709                    let text = if preserve_case {
710                        preserve_case_of(&buffer.slice(r.clone()), replacement)
711                    } else {
712                        replacement.to_string()
713                    };
714                    (r, text)
715                })
716                .collect();
717        }
718    };
719    // Mirrors `scan_buffer_lines` exactly — same lines, same `LineMatcher`
720    // iteration (so whole-word's edge check and cursor rule apply), same scope
721    // filter — then fetches each kept match's capture groups, which expansion
722    // needs and the plain scan throws away.
723    let mut out = Vec::new();
724    if within.start >= within.end {
725        return out;
726    }
727    let first = buffer.offset_to_point(within.start).row;
728    let last = buffer.offset_to_point(within.end).row;
729    for row in first..=last {
730        let base = buffer.point_to_offset(Point::new(row, 0));
731        let line = buffer.line(row); // excludes the trailing `\n`
732        for m in lm.find_iter(&line) {
733            let (s, e) = (base + m.start as u32, base + m.end as u32);
734            if s < within.start || e > within.end {
735                continue; // outside the scope, which may open/close mid-line
736            }
737            // The SAME match, with its groups: no match starts in
738            // `m.start..` before `m.start`, and leftmost-first picks one match
739            // per start, so `captures_at` from the match's own start re-finds
740            // exactly it (with the same line context for `^` / `\b`).
741            let c = lm.re.captures_at(&line, m.start).expect("the match re-finds at its start");
742            debug_assert_eq!(c.get(0).map(|g| g.range()), Some(m.clone()));
743            let mut dst = String::new();
744            c.expand(replacement, &mut dst);
745            // Preserve-case keys off the WHOLE match (group 0), applied AFTER
746            // template expansion — so `$1`-driven text is re-cased too. That is
747            // scrive's extension: VS Code's rule is defined over a plain
748            // replacement string, and this feeds it the expanded one.
749            let text = if preserve_case { preserve_case_of(&line[m], &dst) } else { dst };
750            out.push((s..e, text));
751        }
752    }
753    out
754}
755
756/// Re-case `replacement` to the case *pattern* of `matched` — the engine behind
757/// the replace bar's preserve-case (`AB`) toggle.
758///
759/// This is VS Code's rule (`buildReplaceStringWithCasePreserved`), ported
760/// branch for branch and pinned by its own test table
761/// (`preserve_case_follows_vs_codes_table`), first rule that applies wins:
762///
763/// 1. **Segments.** When both strings contain `-` and split into the same
764///    number of `-`-segments, each segment is re-cased to its counterpart by
765///    this same rule (`foo-BAR` + `new-name` ⇒ `new-NAME`); likewise `_`. If
766///    BOTH separators qualify, neither does — the rest of the rules see the
767///    whole strings.
768/// 2. **All upper** — `matched` equals its own upper-casing ⇒ the replacement
769///    upper-cased. (So a match with no cased letters at all, like `42`, counts
770///    as upper: VS Code's quirk, kept for parity.)
771/// 3. **All lower** ⇒ the replacement lower-cased.
772/// 4. **First char upper** (lower-casing changes it) — `FooBar`, `Abc` ⇒ the
773///    replacement's first char upper-cased, the rest as typed.
774/// 5. **First char lower** (upper-casing changes it) — camelCase `fooBar` ⇒
775///    the replacement's first char lower-cased, the rest as typed.
776/// 6. Otherwise (a caseless first char in a mixed match) ⇒ as typed.
777///
778/// "First char" is the first Unicode scalar value where VS Code reads the
779/// first UTF-16 unit — the same thing except for a match opening with an
780/// astral cased letter (e.g. Deseret), which VS Code cannot case and this can.
781/// Case mappings are Rust's full Unicode ones (`ß` upper-cases to `SS`), as
782/// JavaScript's are.
783fn preserve_case_of(matched: &str, replacement: &str) -> String {
784    let Some(first) = matched.chars().next() else {
785        return replacement.to_string(); // nothing to take a pattern from
786    };
787    // Rule 1: both sides split into the same number of `sep`-segments.
788    let segmented = |sep: char| {
789        matched.contains(sep)
790            && replacement.contains(sep)
791            && matched.split(sep).count() == replacement.split(sep).count()
792    };
793    let sep = match (segmented('-'), segmented('_')) {
794        (true, false) => Some('-'),
795        (false, true) => Some('_'),
796        _ => None, // neither, or both: judge the whole strings
797    };
798    if let Some(sep) = sep {
799        let mut out = String::with_capacity(replacement.len());
800        for (i, (m, r)) in matched.split(sep).zip(replacement.split(sep)).enumerate() {
801            if i > 0 {
802                out.push(sep);
803            }
804            out.push_str(&preserve_case_of(m, r));
805        }
806        return out;
807    }
808    if matched.to_uppercase() == matched {
809        return replacement.to_uppercase(); // rule 2
810    }
811    if matched.to_lowercase() == matched {
812        return replacement.to_lowercase(); // rule 3
813    }
814    // Rules 4–6 re-case only the replacement's first char.
815    let changes = |mapped: &mut dyn Iterator<Item = char>| !mapped.eq([first]);
816    let mut rest = replacement.chars();
817    let Some(r0) = rest.next() else {
818        return String::new(); // an empty replacement has nothing to re-case
819    };
820    let head: String = if changes(&mut first.to_lowercase()) {
821        r0.to_uppercase().collect() // rule 4: first char is upper
822    } else if changes(&mut first.to_uppercase()) {
823        r0.to_lowercase().collect() // rule 5: first char is lower
824    } else {
825        return replacement.to_string(); // rule 6
826    };
827    head + rest.as_str()
828}
829
830/// Whether a tracked range is a find match — the one predicate the store
831/// queries and batch removals share.
832fn is_find(r: &TrackedRange) -> bool {
833    matches!(r.kind, DecorationKind::FindMatch)
834}
835
836/// The search view-state: the active query, the active match's decoration id,
837/// and the coverage/cap bookkeeping.
838///
839/// The one design rule: **the store IS the match set** — the sorted `FindMatch`
840/// decorations, in `(start, id)` = document order. This state deliberately
841/// holds no handle list; count, order, and N-of-M all derive from store queries
842/// (one fact, one owner). The set is repaired eagerly per commit via
843/// [`FindState::on_commit`], so it is always current — undo/redo inherit the
844/// repair through the shared view-rebase mover.
845#[derive(Debug, Default)]
846pub struct FindState {
847    query: Option<FindQuery>,
848    /// The decoration id of the highlighted/navigated match, if any.
849    active: Option<DecorationId>,
850    /// The active match's tracked START offset — the position half of the active
851    /// handle, kept in lockstep with [`active`](Self::active) (both `Some` or
852    /// both `None`). It rides each commit's patch in
853    /// [`on_commit`](Self::on_commit) with **`Bias::Right`** — the start bias of
854    /// a `FindMatch`'s `NeverGrows` stickiness — so it stays equal to the active
855    /// decoration's current start (pinned by `active_start_tracks…`). It earns
856    /// its place as the O(log) index that lets the per-commit presence check and
857    /// the N-of-M ordinal avoid an O(M) whole-store walk, not as a display
858    /// convenience.
859    active_start: Option<u32>,
860    /// Whether the match set is a capped *prefix* of the document.
861    capped: bool,
862    /// Byte offset the scan has covered: `buffer.len()` when `!capped`, else
863    /// the last kept match's end. Rides each commit's patch (`Bias::Left`);
864    /// while capped, repairs clamp their windows to it and edits beyond it are
865    /// the refill's job.
866    coverage_end: u32,
867    /// While capped: the live count has dropped below the cap, so coverage
868    /// could grow — the ONLY condition under which [`FindState::maybe_rescan`]
869    /// re-scans. (At exactly the cap the set provably equals the ideal capped
870    /// prefix: repairs keep the covered prefix exact, and its first
871    /// [`FIND_MATCH_CAP`] matches are the document's first ones.)
872    capped_stale: bool,
873    /// `now_ms` of the last full scan — the capped-refill debounce anchor.
874    last_scan_ms: u64,
875    /// **Find in selection**: when `Some`, matches exist ONLY inside this byte
876    /// range — the scan is restricted to it and every repair window clamps to
877    /// it, exactly as they already clamp to [`coverage_end`](Self::coverage_end).
878    ///
879    /// A position like any other, so it rides each commit's patch in
880    /// [`on_commit`](Self::on_commit) rather than being re-derived: biases
881    /// `(Left, Right)` — the `AlwaysGrows` pair — so text typed at either edge
882    /// lands *inside* the scope. Collapsing to empty drops it (the
883    /// [`EmptyPolicy::Drop`] rule a `FindMatch` follows), because a zero-width
884    /// scope can only mean "no matches ever" and silently searching nothing is
885    /// worse than searching everything.
886    ///
887    /// [`EmptyPolicy::Drop`]: crate::decorations::EmptyPolicy::Drop
888    scope: Option<Range<u32>>,
889    /// `query` compiled — cached so a keystroke's repair never recompiles the
890    /// pattern. Kept in lockstep with [`query`](Self::query) (both are set only
891    /// in [`set_query`](Self::set_query)); `None` iff there is no query or the
892    /// pattern does not parse.
893    matcher: Option<Matcher>,
894    /// Why the pattern does not parse, when `regex` is on and it does not.
895    ///
896    /// Not a failure mode: every prefix of a pattern being typed (`(`, `[a-`) is
897    /// invalid, so this is state to SHOW — matching yields nothing meanwhile and
898    /// resumes the moment the pattern parses again.
899    pattern_error: Option<String>,
900}
901
902impl FindState {
903    /// An empty state — no query, no matches.
904    #[must_use]
905    pub fn new() -> Self {
906        Self::default()
907    }
908
909    /// The active query, or `None` if find is idle.
910    #[must_use]
911    pub fn query(&self) -> Option<&FindQuery> {
912        self.query.as_ref()
913    }
914
915    /// How many live matches the current query has — the store IS the match set,
916    /// so this is the O(1) root-summary [`find_count`] read.
917    ///
918    /// [`find_count`]: DecorationStore::find_count
919    #[must_use]
920    pub fn match_count(&self, store: &DecorationStore) -> usize {
921        store.find_count()
922    }
923
924    /// The decoration handle of the active match, if any — the render layer
925    /// compares each `FindMatch` decoration to this to pick the distinct style.
926    #[must_use]
927    pub fn active_id(&self) -> Option<DecorationId> {
928        self.active
929    }
930
931    /// The active match's tracked start offset — the position half of the active
932    /// handle, in lockstep with [`active_id`](Self::active_id). `Document` reads
933    /// it for the O(log) N-of-M ordinal (`active_find_match`).
934    #[must_use]
935    pub(crate) fn active_start(&self) -> Option<u32> {
936        self.active_start
937    }
938
939    /// The active match's live range — `None` when no match is active, or when
940    /// the tracked start no longer carries it.
941    ///
942    /// O(log M): the tracked start's rank, then that rank's span. Deliberately
943    /// NOT [`DecorationStore::decoration_range`], which is documented as an
944    /// O(store) cold path — keeping [`active_start`](Self::active_start) in
945    /// lockstep exists precisely so the active handle resolves without a
946    /// whole-store walk.
947    #[must_use]
948    pub(crate) fn active_range(&self, store: &DecorationStore) -> Option<Range<u32>> {
949        let (id, start) = (self.active?, self.active_start?);
950        // Finds are disjoint, so only the find starting exactly at `start` can be
951        // the active one — the same identity `navigate` uses to test "on a match".
952        let (found, range) = store.nth_find(store.find_rank_before(start))?;
953        (found == id).then_some(range)
954    }
955
956    /// Whether the match set is a capped prefix of the document
957    /// ([`FIND_MATCH_CAP`]).
958    #[must_use]
959    pub fn capped(&self) -> bool {
960        self.capped
961    }
962
963    /// The find-in-selection scope — matches exist only inside it. `None` means
964    /// the whole document.
965    #[must_use]
966    pub fn scope(&self) -> Option<Range<u32>> {
967        self.scope.clone()
968    }
969
970    /// Set (or clear with `None`) the find-in-selection scope, re-scanning
971    /// synchronously — the scope is part of what matches, so changing it is a
972    /// query change, not a display filter.
973    ///
974    /// An empty range clears the scope rather than pinning zero matches
975    /// forever: searching everything is a recoverable surprise, silently
976    /// searching nothing is not. A scope equal to the current one is a no-op.
977    pub fn set_scope(
978        &mut self,
979        scope: Option<Range<u32>>,
980        buffer: &Buffer,
981        store: &mut DecorationStore,
982        now_ms: u64,
983    ) {
984        let scope = scope.filter(|s| s.start < s.end);
985        if scope == self.scope {
986            return;
987        }
988        self.scope = scope;
989        self.rescan(buffer, store, now_ms);
990    }
991
992    /// Set (or clear with `None`) the query and scan synchronously; never
993    /// scrolls, and clears the active match. A query equal to the current one is
994    /// a no-op (no needless re-scan).
995    ///
996    /// The full scan runs `scan_buffer` — whole-document in coverage but
997    /// windowed in memory, and a capped dense query stops at
998    /// O(bytes-until-cap) instead of copying the rope's tail.
999    pub fn set_query(
1000        &mut self,
1001        query: Option<FindQuery>,
1002        buffer: &Buffer,
1003        store: &mut DecorationStore,
1004        now_ms: u64,
1005    ) {
1006        if query == self.query {
1007            return;
1008        }
1009        self.query = query;
1010        // Compile ONCE here, in lockstep with the query — the per-keystroke
1011        // repair reads this cache rather than re-parsing the pattern.
1012        (self.matcher, self.pattern_error) = match &self.query {
1013            None => (None, None),
1014            Some(q) => match Matcher::compile(q) {
1015                Ok(m) => (Some(m), None),
1016                // An unfinished pattern is a normal state: keep the reason to
1017                // show, match nothing until it parses.
1018                Err(e) => (None, Some(e)),
1019            },
1020        };
1021        self.active = None; // a query change drops the active match
1022        self.active_start = None; // …and its tracked position (lockstep)
1023        self.rescan(buffer, store, now_ms);
1024    }
1025
1026    /// Why the current pattern does not parse, if it does not — for the find bar
1027    /// to show. `None` whenever the query is matching normally (including when
1028    /// it simply has no hits).
1029    #[must_use]
1030    pub fn pattern_error(&self) -> Option<&str> {
1031        self.pattern_error.as_deref()
1032    }
1033
1034    /// The replace plan for the current query inside `within`: every match with
1035    /// the text that should replace it (see [`replacements`] for what a
1036    /// replacement means per mode). Through the CACHED matcher, as every scan
1037    /// and repair is; empty when find is idle or the pattern does not parse.
1038    pub(crate) fn replacements(
1039        &self,
1040        buffer: &Buffer,
1041        within: Range<u32>,
1042        replacement: &str,
1043        preserve_case: bool,
1044    ) -> Vec<(Range<u32>, String)> {
1045        self.matcher.as_ref().map_or_else(Vec::new, |m| {
1046            replacements(buffer, m, within, replacement, preserve_case)
1047        })
1048    }
1049
1050    /// Refill a **capped** match set: re-scan iff a query is active, the set is
1051    /// capped with room below the cap, and the debounce window has elapsed.
1052    /// `now_ms` is an injected monotonic clock — the widget passes real time,
1053    /// the headless suite a fake. Returns whether it scanned.
1054    ///
1055    /// Because the eager per-commit repair keeps the set current, the only job
1056    /// here is growing a capped set's coverage after matches inside it died.
1057    pub fn maybe_rescan(&mut self, buffer: &Buffer, store: &mut DecorationStore, now_ms: u64) -> bool {
1058        if self.query.is_none()
1059            || !(self.capped && self.capped_stale)
1060            || now_ms.saturating_sub(self.last_scan_ms) < default_find_debounce()
1061        {
1062            return false;
1063        }
1064        self.rescan(buffer, store, now_ms);
1065        true
1066    }
1067
1068    /// Repair the match set around a committed patch — the mover hook called
1069    /// from the view-rebase path AFTER `DecorationStore::apply_patch` (it needs
1070    /// post-patch positions). No-op when find is idle.
1071    ///
1072    /// The influence window depends on the matcher. A **literal** query (post-edit
1073    /// coordinates, `k` = the longest a match can be — the needle's byte length
1074    /// when exact, a per-char bound when folding): every affected placement must
1075    /// overlap `new.start−(k−1) .. new.end+(k−1)` (a created/destroyed match
1076    /// contains a changed byte or the join point). A **line-scoped** query
1077    /// (whole-word / regex) instead repairs the whole lines the edit touched —
1078    /// a match cannot cross a newline, so nothing off those lines can have
1079    /// changed — with no `k−1` widening and no anchor/guard (the greedy phase the
1080    /// widening repairs does not exist there). The rest of this describes the
1081    /// literal path. Matches touching the window are removed
1082    /// wholesale ([`DecorationStore::take_matching_in`]); the re-scan zone is
1083    /// then the window widened (a) to the removed extents — a merely-touching
1084    /// match must be re-findable, not lost — and (b) a further `k−1` bytes each
1085    /// way, because a placement overlapping the removal zone (one whose old
1086    /// greedy blocker just died — the self-overlap phase repair) can start up
1087    /// to `k−1` bytes left of it and end as far right. Two clamps keep the
1088    /// zone from double-owning text: the scan starts at the last surviving
1089    /// match ending in the left margin (the anchor), and candidates
1090    /// overlapping the first surviving match right of the zone are dropped
1091    /// (the guard). The zone is greedily re-scanned with the same pure
1092    /// [`scan`] and batch-reinserted. Matches outside every zone keep their
1093    /// decoration ids (id stability is what lets the active match survive
1094    /// unrelated edits).
1095    ///
1096    /// Active survival: untouched active stays (same id); an active removed by a
1097    /// window transfers to a re-created match at its exact post-patch start;
1098    /// otherwise it clears.
1099    ///
1100    /// Cap: `coverage_end` rides the patch first; windows clamp to it, so
1101    /// repairs beyond a capped set's coverage are skipped (the refill's job).
1102    /// If a repair pushes the live count past [`FIND_MATCH_CAP`], the tail is
1103    /// trimmed so the set stays a prefix of the document.
1104    pub fn on_commit(&mut self, patch: &Patch, buffer: &Buffer, store: &mut DecorationStore) {
1105        if self.query.is_none() {
1106            return;
1107        }
1108        // No matcher ⇒ the pattern does not parse ⇒ there are no matches to
1109        // repair (the set is already empty, and stays so until it parses).
1110        let Some(matcher) = &self.matcher else { return };
1111        let line_scoped = matcher.is_line_scoped();
1112        // A literal's `k` is the LONGEST a match can be — the needle's length
1113        // when exact, a bound when folding (a folded match need not be as many
1114        // bytes as the needle; see `LiteralMatcher`). Every margin below only
1115        // needs "no match is longer than k", so the bound is all it takes.
1116        let k = match matcher {
1117            Matcher::Literal(lit) => lit.max_len,
1118            Matcher::Lines(_) => 0, // line-scoped repair never reads `k`
1119        };
1120        if patch.is_empty() || (!line_scoped && k == 0) {
1121            return; // an empty literal needle has no matches to repair
1122        }
1123        // Coverage rides the patch ONCE, before the per-edit loop; an uncapped
1124        // set always covers the whole (post-edit) document.
1125        self.coverage_end = if self.capped {
1126            patch.map_offset(self.coverage_end, Bias::Left)
1127        } else {
1128            buffer.len()
1129        };
1130        // The active handle's position rides the SAME committed patch as the
1131        // decoration it tracks — `Bias::Right`, a FindMatch's `NeverGrows` start
1132        // bias — so it stays equal to that decoration's post-patch start.
1133        self.active_start = self.active_start.map(|s| patch.map_offset(s, Bias::Right));
1134        // The scope is a derived position like any other, so it rides the SAME
1135        // committed patch rather than being re-derived — `Bias::Left`/`Right`
1136        // (the `AlwaysGrows` pair) so text typed at either edge lands inside it.
1137        // Collapsed ⇒ dropped: a zero-width scope can only match nothing, and
1138        // silently searching nothing is worse than searching everything.
1139        self.scope = self.scope.take().and_then(|s| {
1140            let lo = patch.map_offset(s.start, Bias::Left);
1141            let hi = patch.map_offset(s.end, Bias::Right);
1142            (lo < hi).then_some(lo..hi)
1143        });
1144        // Every repair window lives inside the covered prefix AND, when find is
1145        // scoped, inside the scope — matches cannot exist outside either, so an
1146        // edit out there repairs nothing and one straddling an edge repairs only
1147        // the inside. This is the same mechanism as the capped-coverage clamp,
1148        // with one more bound; both are applied AFTER the char-boundary snap, so
1149        // the snap can never carry a window back outside its bound.
1150        let (lo_bound, hi_bound) = match &self.scope {
1151            Some(s) => (s.start, s.end.min(self.coverage_end)),
1152            None => (0, self.coverage_end),
1153        };
1154        let mut removed_active_start: Option<u32> = None;
1155        for e in patch.edits() {
1156            // The influence window, clamped to the covered prefix (and the
1157            // scope). Literal: ±(k−1) bytes, snapped OUTWARD to char boundaries
1158            // — a match no longer than k can start at most k−1 bytes left of a
1159            // changed byte. Line-scoped: the WHOLE lines the edit touches, which
1160            // is the same argument in the only currency a variable-length
1161            // pattern has — a match cannot cross `\n`, so nothing off those
1162            // lines can have been created or destroyed.
1163            let (w_start, w_end) = if line_scoped {
1164                let lines = whole_lines(buffer, &e.new);
1165                (lines.start, lines.end)
1166            } else {
1167                let s = buffer.clip_offset(e.new.start.saturating_sub(k - 1), Bias::Left);
1168                let t_raw = e.new.end.saturating_add(k - 1).min(hi_bound);
1169                (s, buffer.clip_offset(t_raw, Bias::Right))
1170            };
1171            let (w_start, w_end) = (w_start.max(lo_bound), w_end.min(hi_bound));
1172            if w_start > w_end {
1173                continue; // entirely outside the coverage/scope: not our repair
1174            }
1175            let removed = store.take_matching_in(w_start..w_end, is_find);
1176            if let Some(active) = self.active {
1177                if let Some(r) = removed.iter().find(|r| r.id == active) {
1178                    removed_active_start = Some(r.range.start);
1179                    self.active = None;
1180                    self.active_start = None; // lockstep; the transfer below re-sets both
1181                }
1182            }
1183            // Line-scoped: lines are independent, so there is no greedy phase to
1184            // repair and no match can straddle the window's edge (it would have
1185            // to cross a `\n`). The re-scan zone IS the window, and the anchor
1186            // and guard below have nothing to do — the whole `k−1` widening
1187            // dance exists only to serve a bounded-length needle's greedy cursor.
1188            let (scan_lo, scan_hi, guard) = if line_scoped {
1189                (w_start, w_end, None)
1190            } else {
1191            // Widen to the removed extents: a match that merely touched the
1192            // window (start left of it / end right of it) must be re-findable,
1193            // or removal would lose it.
1194            let ext_lo =
1195                removed.iter().map(|r| r.range.start).min().map_or(w_start, |s| s.min(w_start));
1196            let ext_hi =
1197                removed.iter().map(|r| r.range.end).max().map_or(w_end, |e| e.max(w_end));
1198            // Widen a further k−1 each way: a placement overlapping the
1199            // removal zone — one whose old greedy blocker died (self-overlap
1200            // phase) — can start up to k−1 bytes left of `ext_lo` and end as
1201            // far right of `ext_hi`. Removed-match boundaries are char
1202            // boundaries (whole-char hits of a valid-UTF-8 needle); the ±(k−1)
1203            // arithmetic can unsnap, so clip again.
1204            let scan_lo =
1205                buffer.clip_offset(ext_lo.saturating_sub(k - 1), Bias::Left).max(lo_bound);
1206            let scan_hi = buffer
1207                .clip_offset(ext_hi.saturating_add(k - 1).min(hi_bound), Bias::Right)
1208                .min(hi_bound);
1209            // Left anchor: the widened margin may hold a surviving match —
1210            // start scanning at its end so its text is never double-owned.
1211            // (Survivors intersecting `scan_lo..ext_lo` provably end at or
1212            // before `ext_lo`: anything reaching further would have touched
1213            // the window or a removed extent and been removed itself.)
1214            let anchor = store
1215                .decorations_in(scan_lo..ext_lo)
1216                .filter(is_find)
1217                .map(|r| r.range.end)
1218                .max();
1219            let scan_lo = anchor.map_or(scan_lo, |a| a.max(scan_lo));
1220            // Right guard: candidates may not overlap the first surviving
1221            // match right of the zone (survivors there provably start at or
1222            // after `ext_hi`, same argument as the anchor).
1223            let guard = store
1224                .decorations_in(ext_hi..scan_hi)
1225                .filter(|r| is_find(r) && r.range.start >= ext_hi)
1226                .map(|r| r.range.start)
1227                .min();
1228                (scan_lo, scan_hi, guard)
1229            };
1230            // Re-scan the zone through the SAME cached matcher the display scan
1231            // runs, so a repaired match cannot differ from a freshly found one.
1232            let mut spans: Vec<Range<u32>> = match matcher {
1233                Matcher::Lines(lm) => {
1234                    scan_buffer_lines(buffer, lm, FIND_MATCH_CAP, scan_lo..scan_hi).0
1235                }
1236                Matcher::Literal(lit) => lit
1237                    .scan(&buffer.slice(scan_lo..scan_hi), FIND_MATCH_CAP)
1238                    .0
1239                    .into_iter()
1240                    .map(|r| scan_lo + r.start..scan_lo + r.end)
1241                    .collect(),
1242            };
1243            if let Some(guard) = guard {
1244                spans.retain(|s| s.end <= guard);
1245            }
1246            // Windowed insert: the batch merges into its ≤band without the
1247            // O(M log M) whole-store `to_vec()`+re-sort of `add_sorted_batch`.
1248            // Byte-identical to that here (the window is already cleared of
1249            // finds), pinned by `splice_batch_equals_naive`.
1250            store.splice_sorted_batch(&spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
1251        }
1252        // An active match a window removed transfers to a re-created match at
1253        // its exact (post-patch) start, if any — the position half moves with
1254        // it (lockstep).
1255        if let Some(start) = removed_active_start {
1256            let found = store
1257                .decorations_in(start..start)
1258                .find(|r| is_find(r) && r.range.start == start);
1259            self.active = found.as_ref().map(|r| r.id);
1260            self.active_start = found.map(|r| r.range.start);
1261        }
1262        // Cap bookkeeping — only when it can matter. The O(1) `find_count`
1263        // gates it (not `store.len()`, which counts diagnostics/snippets too, so
1264        // gating on it would scale with unrelated decorations). The BODY is
1265        // O(log) too: the live count is `find_count()` (O(1) root read) and the
1266        // two trim boundaries are ordinal `nth_find` lookups (O(log M)) — so a
1267        // capped commit costs the same regardless of decoration count (pinned by
1268        // `capped_commit_is_decoration_count_independent`).
1269        let fc = store.find_count();
1270        if fc > FIND_MATCH_CAP {
1271            // Trim from the TAIL: the set stays a prefix of the document. The two
1272            // boundaries are the (cap-1)-th and cap-th finds in `(start,id)` order.
1273            if let (Some((_, kept)), Some((_, first_trimmed))) =
1274                (store.nth_find(FIND_MATCH_CAP - 1), store.nth_find(FIND_MATCH_CAP))
1275            {
1276                let (kept_end, trim_from) = (kept.end, first_trimmed.start);
1277                store.take_matching_in(trim_from..u32::MAX, |r| {
1278                    is_find(r) && r.range.start >= trim_from
1279                });
1280                self.capped = true;
1281                self.coverage_end = kept_end;
1282                self.capped_stale = false; // full to the brim — nothing to refill
1283            }
1284        } else if self.capped {
1285            // Below the cap with frozen coverage: the refill can grow it.
1286            self.capped_stale = fc < FIND_MATCH_CAP;
1287        }
1288        // An active match destroyed without passing through a window (e.g.
1289        // collapsed and dropped by `apply_patch`, or tail-trimmed) clears —
1290        // probed at the tracked start (O(log)). `active_start` tracks the
1291        // decoration's start exactly (Bias::Right), so a present match overlaps
1292        // the zero-width `s..s` query.
1293        if let (Some(id), Some(s)) = (self.active, self.active_start) {
1294            let present = store.decorations_in(s..s).any(|r| is_find(&r) && r.id == id);
1295            if !present {
1296                self.active = None;
1297                self.active_start = None;
1298            }
1299        }
1300    }
1301
1302    /// Select the next match from the caret and return its live range. The set
1303    /// is always current, so this is a pure store walk. `head`/`selection` are
1304    /// the newest selection's head and extent.
1305    pub fn find_next(
1306        &mut self,
1307        head: u32,
1308        selection: Range<u32>,
1309        store: &DecorationStore,
1310    ) -> Option<Range<u32>> {
1311        self.navigate(head, selection, store, true)
1312    }
1313
1314    /// Select the previous match from the caret — the
1315    /// [`find_next`](Self::find_next) mirror.
1316    pub fn find_prev(
1317        &mut self,
1318        head: u32,
1319        selection: Range<u32>,
1320        store: &DecorationStore,
1321    ) -> Option<Range<u32>> {
1322        self.navigate(head, selection, store, false)
1323    }
1324
1325    /// Wholesale-replace the `FindMatch` decorations from a fresh scan — the
1326    /// query-change path and the capped refill (the two legitimately
1327    /// whole-document find ops). The active match survives iff a new match
1328    /// starts exactly at the old active's tracked start.
1329    fn rescan(&mut self, buffer: &Buffer, store: &mut DecorationStore, now_ms: u64) {
1330        let old_active_start =
1331            self.active.and_then(|id| store.decoration_range(id)).map(|r| r.start);
1332        store.take_matching_in(0..u32::MAX, is_find);
1333        self.active = None;
1334        self.active_start = None;
1335        if self.query.is_some() {
1336            // Scoped find scans only its range; unscoped, the whole document.
1337            let within = self.scope.clone().unwrap_or(0..buffer.len());
1338            // Through the CACHED matcher — the same one every repair uses. None
1339            // ⇒ the pattern does not parse ⇒ it matches nothing (until it does).
1340            let (spans, capped) = match &self.matcher {
1341                Some(m) => scan_buffer(buffer, m, crate::buffer::SCAN_WINDOW, within),
1342                None => (Vec::new(), false),
1343            };
1344            self.capped = capped;
1345            self.coverage_end =
1346                if capped { spans.last().map_or(0, |s| s.end) } else { buffer.len() };
1347            let ids = store.add_sorted_batch(
1348                spans.iter().cloned(),
1349                DecorationKind::FindMatch,
1350                Stickiness::NeverGrows,
1351            );
1352            if let Some(start) = old_active_start {
1353                if let Some(i) = spans.iter().position(|s| s.start == start) {
1354                    self.active = Some(ids[i]); // the active match survives
1355                    self.active_start = Some(spans[i].start); // == start (lockstep)
1356                }
1357            }
1358        } else {
1359            self.capped = false;
1360            self.coverage_end = buffer.len();
1361        }
1362        self.capped_stale = false;
1363        self.last_scan_ms = now_ms;
1364    }
1365
1366    /// Pick the next/previous match relative to the caret and set the active
1367    /// id: [`pick`](Self::pick) then [`activate`](Self::activate).
1368    fn navigate(
1369        &mut self,
1370        head: u32,
1371        selection: Range<u32>,
1372        store: &DecorationStore,
1373        forward: bool,
1374    ) -> Option<Range<u32>> {
1375        self.activate(Self::pick(head, selection, store, forward))
1376    }
1377
1378    /// Make `target` (a [`pick`](Self::pick)) the active match and return its
1379    /// range — or, with no match to go to, clear the active one. Navigation's
1380    /// state half, split from the pure pick so a verb that has to choose the
1381    /// match inside its commit (`Document::replace_next`, whose redo must
1382    /// restore the selection it advanced to) picks there and activates after.
1383    pub(crate) fn activate(&mut self, target: Option<(DecorationId, Range<u32>)>) -> Option<Range<u32>> {
1384        let Some((id, range)) = target else {
1385            self.active = None;
1386            self.active_start = None;
1387            return None;
1388        };
1389        self.active = Some(id);
1390        self.active_start = Some(range.start);
1391        Some(range)
1392    }
1393
1394    /// The match navigation goes to from the caret, as `(id, range)` — a pure
1395    /// read of the store. The live set is the store's `FindMatch` decorations in
1396    /// `(start, id)` = document order; collapsed (empty) ones are skipped
1397    /// (normally vacuous, since the mover drops them). Sitting exactly on a match
1398    /// steps by position (repeated-press cycling); otherwise it scans from `head`
1399    /// and wraps. `None` only when there are no matches.
1400    pub(crate) fn pick(
1401        head: u32,
1402        selection: Range<u32>,
1403        store: &DecorationStore,
1404        forward: bool,
1405    ) -> Option<(DecorationId, Range<u32>)> {
1406        // The live set is the store's `FindMatch` decorations in `(start, id)`
1407        // order — but we never materialize it. Count, rank, and the r-th match
1408        // are three O(log) store queries that reproduce the full-list branch
1409        // table exactly (pinned by `navigate_equals…`).
1410        let count = store.find_count();
1411        if count == 0 {
1412            return None;
1413        }
1414        // "On a match" ⇔ the find starting at `selection.start` IS `selection`.
1415        // `find_rank_before(selection.start)` is that find's rank in one descent;
1416        // only a find whose start equals `selection.start` can equal `selection`
1417        // (finds are disjoint), so this reproduces `position(|r| *r == selection)`.
1418        let on = {
1419            let r = store.find_rank_before(selection.start);
1420            store.nth_find(r).filter(|(_, rng)| *rng == selection).map(|_| r)
1421        };
1422        let pick = match (on, forward) {
1423            (Some(r), true) => (r + 1) % count,
1424            (Some(r), false) => (r + count - 1) % count,
1425            // First find with start ≥ head, wrapping past the last to 0.
1426            (None, true) => store.find_rank_before(head) % count,
1427            // Last find with start < head, wrapping before the first to count−1.
1428            (None, false) => (store.find_rank_before(head) + count - 1) % count,
1429        };
1430        store.nth_find(pick)
1431    }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437
1438    fn q(text: &str, case_sensitive: bool) -> FindQuery {
1439        FindQuery { text: text.into(), case_sensitive, ..Default::default() }
1440    }
1441
1442    /// A whole-word query — the `ab|` option.
1443    fn qw(text: &str, case_sensitive: bool) -> FindQuery {
1444        FindQuery { text: text.into(), case_sensitive, whole_word: true, ..Default::default() }
1445    }
1446
1447    /// A regex query — the `.*` option.
1448    fn qr(text: &str, case_sensitive: bool) -> FindQuery {
1449        FindQuery { text: text.into(), case_sensitive, regex: true, ..Default::default() }
1450    }
1451
1452    #[test]
1453    fn matches_are_leftmost_and_non_overlapping() {
1454        // "aaaa" / "aa" pairs into two, not three overlapping.
1455        assert_eq!(scan("aaaa", &q("aa", true)), (vec![0..2, 2..4], false));
1456        // "ababa" / "aba": second probe starts at 3, "ba" is too short → one hit.
1457        let (spans, capped) = scan("ababa", &q("aba", true));
1458        assert_eq!((spans.len(), spans.first().cloned(), capped), (1, Some(0..3), false));
1459    }
1460
1461    #[test]
1462    fn case_fold_is_toggled() {
1463        assert_eq!(scan("AbAb", &q("ab", false)), (vec![0..2, 2..4], false));
1464        assert_eq!(scan("AbAb", &q("ab", true)).0, Vec::<Range<u32>>::new());
1465        assert_eq!(scan("AbAb", &q("Ab", true)), (vec![0..2, 2..4], false));
1466    }
1467
1468    /// Literal and regex modes fold case IDENTICALLY — Unicode simple case
1469    /// folding in both — so toggling `.*` never changes whether `é` finds `É`.
1470    /// Expected spans come from Unicode's `CaseFolding.txt` (status C/S), not
1471    /// from either engine. The fold partners include ones of a DIFFERENT UTF-8
1472    /// length (`K` KELVIN SIGN is 3 bytes, `k` 1; `ſ` LONG S 2, `s` 1; `ẞ` 3,
1473    /// `ß` 2), so a literal match's byte length is not the needle's.
1474    #[test]
1475    fn literal_and_regex_fold_case_identically() {
1476        let cases: [(&str, &str, Vec<Range<u32>>); 9] = [
1477            ("é", "É é", vec![0..2, 3..5]),
1478            ("É", "é É", vec![0..2, 3..5]),
1479            // Final sigma folds to σ too (`03C2; C; 03C3`).
1480            ("σ", "Σ σ ς", vec![0..2, 3..5, 6..8]),
1481            ("Σ", "ς σ", vec![0..2, 3..5]),
1482            ("k", "\u{212A} k K", vec![0..3, 4..5, 6..7]),
1483            ("s", "\u{17F} S s", vec![0..2, 3..4, 5..6]),
1484            ("ß", "\u{1E9E} ß", vec![0..3, 4..6]),
1485            ("ſé", "SÉ sé", vec![0..3, 4..7]),
1486            // Simple folding only: no multi-char expansions (`ß` ↛ `ss`).
1487            ("ss", "ß", vec![]),
1488        ];
1489        for (needle, text, want) in cases {
1490            for regex in [false, true] {
1491                let query =
1492                    FindQuery { text: needle.into(), case_sensitive: false, regex, ..Default::default() };
1493                assert_eq!(scan(text, &query).0, want, "{needle:?} in {text:?} (regex={regex})");
1494            }
1495        }
1496        // Case-sensitive, both modes still tell the forms apart.
1497        for regex in [false, true] {
1498            let query = FindQuery { text: "é".into(), case_sensitive: true, regex, ..Default::default() };
1499            assert_eq!(scan("É é", &query).0, vec![3..5], "case-sensitive (regex={regex})");
1500        }
1501    }
1502
1503    /// The claim `LiteralMatcher::fold_width` rests on, taken EXHAUSTIVELY
1504    /// from the regex crate (the owner of folding), not from memory: the only
1505    /// non-ASCII chars that `(?i)` folds into ASCII are `ſ` LONG S and `K`
1506    /// KELVIN SIGN, and the width bound of every ASCII char covers each of its
1507    /// fold partners. A Unicode bump that adds a partner fails here, not in a
1508    /// silently-too-narrow repair window.
1509    #[test]
1510    fn fold_width_covers_every_fold_partner_of_ascii() {
1511        let all_non_ascii: String = (0x80..=char::MAX as u32).filter_map(char::from_u32).collect();
1512        let into_ascii = regex::RegexBuilder::new(r"[\x00-\x7F]")
1513            .case_insensitive(true)
1514            .build()
1515            .expect("compiles");
1516        let partners: Vec<char> =
1517            into_ascii.find_iter(&all_non_ascii).flat_map(|m| m.as_str().chars()).collect();
1518        assert_eq!(partners, ['\u{17F}', '\u{212A}'], "the non-ASCII chars that fold into ASCII");
1519        for x in partners {
1520            for a in (0u8..0x80).map(char::from) {
1521                let pattern = format!("^{}$", regex::escape(a.encode_utf8(&mut [0; 4])));
1522                let re = regex::RegexBuilder::new(&pattern).case_insensitive(true).build().unwrap();
1523                if re.is_match(x.encode_utf8(&mut [0; 4])) {
1524                    assert!(
1525                        LiteralMatcher::fold_width(a) >= x.len_utf8() as u32,
1526                        "{a:?} folds with {x:?} ({} bytes)",
1527                        x.len_utf8()
1528                    );
1529                }
1530            }
1531        }
1532    }
1533
1534    #[test]
1535    fn empty_query_matches_nothing() {
1536        assert_eq!(scan("anything", &q("", false)), (Vec::new(), false));
1537        assert_eq!(scan("anything", &q("", true)), (Vec::new(), false));
1538    }
1539
1540    #[test]
1541    fn a_query_longer_than_the_text_finds_nothing() {
1542        assert_eq!(scan("hi", &q("hello", true)), (Vec::new(), false));
1543    }
1544
1545    #[test]
1546    fn scan_equals_the_naive_oracle() {
1547        // Both literal paths must be bit-identical to the straightforward naive
1548        // scan, kept here verbatim as the oracle. Its byte-wise ASCII fold IS
1549        // Unicode simple folding on this alphabet (no `k`/`s`, whose fold
1550        // partners are non-ASCII, and nothing beyond ASCII).
1551        fn naive(text: &str, query: &FindQuery) -> (Vec<Range<u32>>, bool) {
1552            let needle = query.text.as_bytes();
1553            if needle.is_empty() {
1554                return (Vec::new(), false);
1555            }
1556            let hay = text.as_bytes();
1557            let hit = |window: &[u8]| {
1558                if query.case_sensitive {
1559                    window == needle
1560                } else {
1561                    window.iter().zip(needle).all(|(a, b)| a.eq_ignore_ascii_case(b))
1562                }
1563            };
1564            let mut spans = Vec::new();
1565            let mut i = 0;
1566            while i + needle.len() <= hay.len() {
1567                if hit(&hay[i..i + needle.len()]) {
1568                    if spans.len() == FIND_MATCH_CAP {
1569                        return (spans, true);
1570                    }
1571                    spans.push(i as u32..(i + needle.len()) as u32);
1572                    i += needle.len();
1573                } else {
1574                    i += 1;
1575                }
1576            }
1577            (spans, false)
1578        }
1579
1580        let mut rng = 0x2545F4914F6CDD1Du64;
1581        let mut next = move || {
1582            rng ^= rng << 13;
1583            rng ^= rng >> 7;
1584            rng ^= rng << 17;
1585            rng
1586        };
1587        let alphabet = ['a', 'A', 'b', 'B', 'c', ' '];
1588        for round in 0..200 {
1589            let text: String = (0..(next() % 120)).map(|_| alphabet[(next() % 6) as usize]).collect();
1590            let needle: String = (0..1 + (next() % 4)).map(|_| alphabet[(next() % 6) as usize]).collect();
1591            for cs in [true, false] {
1592                let query = q(&needle, cs);
1593                assert_eq!(
1594                    scan(&text, &query),
1595                    naive(&text, &query),
1596                    "round {round}: {needle:?} (cs={cs}) in {text:?}"
1597                );
1598            }
1599        }
1600    }
1601
1602    #[test]
1603    fn scan_caps_and_reports() {
1604        // One past the cap trips it (both case paths)…
1605        let text = "ab".repeat(FIND_MATCH_CAP + 5);
1606        let (spans, capped) = scan(&text, &q("ab", true));
1607        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, true));
1608        let text = "aB".repeat(FIND_MATCH_CAP + 1);
1609        let (spans, capped) = scan(&text, &q("ab", false));
1610        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, true));
1611        // …exactly the cap does not (the cap trips only on an overflowing hit).
1612        let text = "ab".repeat(FIND_MATCH_CAP);
1613        let (spans, capped) = scan(&text, &q("ab", true));
1614        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, false));
1615    }
1616
1617    /// The windowed buffer scan must be byte-identical to `scan` over the
1618    /// materialized text — spans AND capped flag — for every window size,
1619    /// including windows tiny enough that matches straddle every seam, the
1620    /// needle exceeds the window, multibyte chars sit on window ends (the
1621    /// boundary-snap paths), and self-overlapping needles cross seams. The
1622    /// KELVIN SIGN / LONG S corpus makes folded matches far longer than their
1623    /// needle (`kkkkks` ↔ the 17-byte `KKKKKſ`), so a seam overlap sized from
1624    /// the needle rather than the longest match drops the straddlers.
1625    #[test]
1626    fn windowed_scan_equals_the_whole_text_scan() {
1627        let corpora = [
1628            String::new(),
1629            "aaaa".into(),
1630            "abababab".into(),
1631            "aa aa aaa aaaa a".into(),
1632            "the fox\nreturns the fox to the FOX den\nfoxfoxfox\n".repeat(40),
1633            // Multibyte chars packed so window ends land mid-char for most
1634            // small window sizes; matches sit between and across them.
1635            "ä🦀ab🦀äab日本語ab".repeat(30),
1636            "🦀🦀🦀ab🦀🦀🦀".repeat(50),
1637            "\u{212A}\u{212A}\u{212A}\u{212A}\u{212A}ſ kkkkks KK\u{212A}kKS \u{212A}ſ ks ".repeat(20),
1638        ];
1639        let needles = [
1640            "a", "ab", "aa", "aaa", "fox", "FOX", "🦀ä", "ab🦀", "abababababab", "語ab", "ks", "kkkkks",
1641        ];
1642        for text in &corpora {
1643            let b = Buffer::new(text).unwrap();
1644            let full_text = b.text();
1645            for needle in needles {
1646                for cs in [true, false] {
1647                    let query = q(needle, cs);
1648                    let expect = scan(&full_text, &query);
1649                    let m = Matcher::compile(&query).expect("a literal compiles");
1650                    for window in [2, 3, 5, 7, 16, 64, 4096] {
1651                        assert_eq!(
1652                            scan_buffer(&b, &m, window, 0..b.len()),
1653                            expect,
1654                            "window {window}, needle {needle:?} (cs={cs}) in {:?}…",
1655                            &text[..text.len().min(24)]
1656                        );
1657                    }
1658                }
1659            }
1660        }
1661    }
1662
1663    /// Whole-word boundaries at a LINE's edges. The character outside a match at
1664    /// the start or end of a line is `\n` (or the document edge) — both
1665    /// non-word — so such a word IS whole. This is precisely the case the
1666    /// line-scoped model gets right for free and a byte-window scan cannot: a
1667    /// window's slice does not contain the character before its own first byte.
1668    #[test]
1669    fn whole_word_is_bounded_by_line_edges() {
1670        //          0123456 7 89.. 11 12....18 19
1671        let text = "foo bar\nfoo\nbarfoo foo";
1672        assert_eq!(
1673            scan(text, &qw("foo", false)).0,
1674            vec![0..3, 8..11, 19..22],
1675            "line-start, whole-line, and line-end words are whole; `barfoo` is not"
1676        );
1677        // Without the option, the substring inside `barfoo` matches too.
1678        assert_eq!(scan(text, &q("foo", false)).0, vec![0..3, 8..11, 15..18, 19..22]);
1679    }
1680
1681    /// A regex can never match across a line: the engine is fed one line at a
1682    /// time WITHOUT its newline, so `\n` is not in the haystack at all — this is
1683    /// structural, not a convention, and it is what keeps the per-keystroke
1684    /// repair windowed for a variable-length pattern.
1685    #[test]
1686    fn a_regex_can_never_match_across_a_line() {
1687        let text = "foo\nbar";
1688        for pattern in [r"foo\nbar", r"foo.bar", r"foo\s+bar", r"foo[^x]bar"] {
1689            assert!(
1690                scan(text, &qr(pattern, false)).0.is_empty(),
1691                "{pattern:?} must not span the newline"
1692            );
1693        }
1694        // …while each line matches on its own, with `^`/`$` meaning its edges.
1695        assert_eq!(scan(text, &qr("^foo$", false)).0, vec![0..3]);
1696        assert_eq!(scan(text, &qr("^bar$", false)).0, vec![4..7]);
1697        assert_eq!(scan(text, &qr("[a-z]+", false)).0, vec![0..3, 4..7]);
1698    }
1699
1700    /// An unfinished pattern is a NORMAL state — it is what every prefix of a
1701    /// regex being typed looks like — so it matches nothing rather than
1702    /// erroring, and resumes the moment it parses.
1703    #[test]
1704    fn an_unfinished_pattern_matches_nothing() {
1705        for pattern in ["(", "[a-", "a{2", "*"] {
1706            assert!(scan("aaa (bbb)", &qr(pattern, false)).0.is_empty(), "{pattern:?}");
1707        }
1708        assert_eq!(scan("aaa (bbb)", &qr(r"\(b+\)", false)).0, vec![4..9]);
1709    }
1710
1711    /// Zero-width hits are dropped: there is nothing to navigate to, highlight,
1712    /// or replace, and the store would discard them anyway — so the count stays
1713    /// honest about what the user can act on.
1714    #[test]
1715    fn zero_width_regex_hits_are_dropped() {
1716        assert!(scan("bbb\n\nbbb", &qr("a*", false)).0.is_empty());
1717        assert_eq!(scan("bab", &qr("a*", false)).0, vec![1..2], "only the non-empty hit");
1718    }
1719
1720    /// A needle whose edge character is punctuation is still findable as a
1721    /// whole word — the four repros of the `\b…\b` bug, where `\b` before `(`
1722    /// demanded a word character *outside* the match and so could never hold.
1723    /// The rule (VS Code's): a side whose edge character is a word character
1724    /// needs a non-word neighbour (or the line edge); a side whose edge
1725    /// character is a separator matches whatever its neighbour is.
1726    #[test]
1727    fn whole_word_matches_a_needle_with_punctuation_edges() {
1728        assert_eq!(scan(" (foo) ", &qw("(foo", false)).0, vec![1..5], "`(foo` inside parens");
1729        assert_eq!(scan("a == b", &qw("==", false)).0, vec![2..4], "an all-punctuation needle");
1730        //               0123456789.123456789
1731        assert_eq!(
1732            scan("foo.bar afoo. foo.", &qw("foo.", false)).0,
1733            vec![0..4, 14..18],
1734            "the `.` side is free (even against `b`); the `f` side still needs a boundary"
1735        );
1736        //               0123456789.1234
1737        assert_eq!(
1738            scan("x = -1; y-1 -12", &qw("-1", false)).0,
1739            vec![4..6, 9..11],
1740            "the `-` side is free (even against `y`); the `1` side rejects `-12`"
1741        );
1742    }
1743
1744    /// A whole-word word needle is still rejected inside a longer word on either
1745    /// side — the half of the rule the punctuation fix must not loosen. `_` is a
1746    /// word character (the `movement::is_word_char` rule), so `foo_x` is one word.
1747    #[test]
1748    fn whole_word_still_rejects_a_word_inside_a_longer_word() {
1749        //          0123456789.123456789.123456
1750        let text = "foobar barfoo foo_x xfoo foo";
1751        assert_eq!(scan(text, &qw("foo", false)).0, vec![25..28]);
1752        assert_eq!(scan(text, &qw("foo", true)).0, vec![25..28]);
1753    }
1754
1755    /// Whole-word REGEX judges each match by its own real edge characters —
1756    /// what the pattern actually matched, not the pattern's spelling — so
1757    /// `\(foo` behaves exactly like the literal `(foo`, and an optional
1758    /// `\(?` prefix makes the `(` side free only on the matches that took it.
1759    #[test]
1760    fn whole_word_regex_judges_each_matchs_real_edges() {
1761        let qrw = |p: &str| FindQuery {
1762            text: p.into(),
1763            whole_word: true,
1764            regex: true,
1765            ..Default::default()
1766        };
1767        assert_eq!(scan(" (foo) ", &qrw(r"\(foo")).0, vec![1..5], r"`\(foo` ≡ the literal `(foo`");
1768        //          0123456789.12
1769        let text = "(foo afoo foo";
1770        assert_eq!(
1771            scan(text, &qrw(r"\(?foo")).0,
1772            vec![0..4, 10..13],
1773            "the match that took the `(` is free on its left; the bare one inside `afoo` is not"
1774        );
1775        // The match's own edges, per match: `.foo` hits `(foo` (separator edge,
1776        // free) and ` afoo`'s `afoo` (word edge, bounded by the space) but not
1777        // `xafoo`'s `afoo` (word edge against `x`).
1778        //               0123456789.1234
1779        assert_eq!(scan("(foo afoo xafoo", &qrw(".foo")).0, vec![0..4, 5..9]);
1780    }
1781
1782    /// Whole-word compiles the ESCAPED literal, so regex metacharacters in a
1783    /// plain whole-word query stay literal.
1784    #[test]
1785    fn whole_word_escapes_its_literal() {
1786        assert_eq!(scan("a.c abc", &qw("a.c", false)).0, vec![0..3], "`.` is literal here");
1787        // …and a whole-word REGEX edge-checks every alternative's match, so
1788        // alternation is bounded on both sides of each branch (the old wrapping
1789        // needed a `(?:…)` group for this; an edge check never splits a pattern).
1790        let q = FindQuery {
1791            text: "foo|bar".into(),
1792            whole_word: true,
1793            regex: true,
1794            ..Default::default()
1795        };
1796        assert_eq!(scan("foo bar foobar", &q).0, vec![0..3, 4..7], "neither inside `foobar`");
1797    }
1798
1799    /// Replace-all walks the SAME whole-word iteration as the display scan and
1800    /// expands each kept match's own groups: the `(fo` inside `(foo` is
1801    /// rejected (its `o` edge meets `o`), the other two — `(` edges, free of
1802    /// their `x` / space neighbours — are replaced with `$1` expanded.
1803    #[test]
1804    fn whole_word_regex_replace_expands_only_the_kept_matches() {
1805        let q = FindQuery {
1806            text: r"\((fo)".into(),
1807            whole_word: true,
1808            regex: true,
1809            ..Default::default()
1810        };
1811        //          0123456789.1234
1812        let text = "(foo) (fo) x(fo";
1813        let b = Buffer::new(text).unwrap();
1814        let m = Matcher::compile(&q).expect("the pattern compiles");
1815        assert_eq!(
1816            replacements(&b, &m, 0..b.len(), "<$1>", false),
1817            vec![(6..9, "<fo>".to_string()), (12..15, "<fo>".to_string())]
1818        );
1819        assert_eq!(scan(text, &q).0, vec![6..9, 12..15], "the display scan agrees");
1820    }
1821
1822    /// A rejected whole-word candidate resumes one char past its START, so it
1823    /// can never hide a valid match that begins inside it: in `xa-a-a`, `1..4`
1824    /// fails against the `x`, and `3..6` — bounded by the `-` — is still found.
1825    #[test]
1826    fn a_rejected_whole_word_candidate_does_not_hide_a_later_one() {
1827        assert_eq!(scan("xa-a-a", &qw("a-a", true)).0, vec![3..6]);
1828    }
1829
1830    /// Without whole-word, the line iterator must be byte-identical to the
1831    /// regex crate's own `find_iter` with zero-width hits dropped — the plain
1832    /// regex mode routes through the new cursor too, and must not change. The
1833    /// empty-match-heavy patterns (`a*|b`, `\b`, `$|a`) are the ones whose
1834    /// resume rule differs most visibly if the two ever drift.
1835    #[test]
1836    fn line_matches_without_whole_word_equal_find_iter() {
1837        let mut rng = 0x9E37_79B9_7F4A_7C15u64;
1838        let mut next = move || {
1839            rng ^= rng << 13;
1840            rng ^= rng >> 7;
1841            rng ^= rng << 17;
1842            rng
1843        };
1844        let alphabet = ['a', 'b', 'A', ' ', '_', '(', 'é', '🦀', '1'];
1845        let patterns = [
1846            "a", "a*", "a*|b", r"\b", "x?", "[ab]+", "(?:)", "é*", "^", "$|a", ".", r"\w+",
1847            "b|", "a??", r"\(a", "🦀|a",
1848        ];
1849        for pattern in patterns {
1850            for cs in [true, false] {
1851                let Ok(Matcher::Lines(lm)) = Matcher::compile(&qr(pattern, cs)) else {
1852                    panic!("{pattern:?} compiles to the line engine");
1853                };
1854                for round in 0..60 {
1855                    let line: String =
1856                        (0..(next() % 24)).map(|_| alphabet[(next() % 9) as usize]).collect();
1857                    let want: Vec<Range<usize>> = lm
1858                        .re
1859                        .find_iter(&line)
1860                        .filter(|m| m.start() < m.end())
1861                        .map(|m| m.range())
1862                        .collect();
1863                    let got: Vec<Range<usize>> = lm.find_iter(&line).collect();
1864                    assert_eq!(got, want, "round {round}: {pattern:?} (cs={cs}) in {line:?}");
1865                }
1866            }
1867        }
1868    }
1869
1870    /// Whole-word against an INDEPENDENT oracle: the greedy, leftmost,
1871    /// non-overlapping set of valid placements, judged byte by byte straight
1872    /// from the rule (a side fails only when its edge byte and its neighbour
1873    /// are both word bytes; `\n` and the text's ends are never word bytes).
1874    /// Random ASCII corpora rich in punctuation, `_`, and newlines, every
1875    /// needle both as a whole-word literal AND as the whole-word regex of its
1876    /// escape — which must agree (`\(foo` ≡ `(foo`).
1877    #[test]
1878    fn whole_word_equals_the_naive_placement_oracle() {
1879        fn naive(text: &str, needle: &str, cs: bool) -> Vec<Range<u32>> {
1880            let (hay, nd) = (text.as_bytes(), needle.as_bytes());
1881            let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1882            let mut out = Vec::new();
1883            let mut i = 0;
1884            while !nd.is_empty() && i + nd.len() <= hay.len() {
1885                let (w, j) = (&hay[i..i + nd.len()], i + nd.len());
1886                let hit = if cs { w == nd } else { w.eq_ignore_ascii_case(nd) };
1887                let left = !(word(w[0]) && i > 0 && word(hay[i - 1]));
1888                let right = !(word(w[nd.len() - 1]) && j < hay.len() && word(hay[j]));
1889                if hit && left && right {
1890                    out.push(i as u32..j as u32);
1891                    i = j;
1892                } else {
1893                    i += 1;
1894                }
1895            }
1896            out
1897        }
1898
1899        let mut rng = 0x0DDB_A11C_AFE5_EED5u64;
1900        let mut next = move || {
1901            rng ^= rng << 13;
1902            rng ^= rng >> 7;
1903            rng ^= rng << 17;
1904            rng
1905        };
1906        let alphabet = ['a', 'A', 'b', '_', '1', '(', '.', '-', '=', ' ', '\n'];
1907        let mut nonempty = 0;
1908        for round in 0..400 {
1909            let text: String =
1910                (0..(next() % 80)).map(|_| alphabet[(next() % 11) as usize]).collect();
1911            // Needles never hold `\n`: the line-scoped engine cannot match one.
1912            let needle: String =
1913                (0..1 + (next() % 4)).map(|_| alphabet[(next() % 10) as usize]).collect();
1914            for cs in [true, false] {
1915                let want = naive(&text, &needle, cs);
1916                nonempty += usize::from(!want.is_empty());
1917                assert_eq!(
1918                    scan(&text, &qw(&needle, cs)).0,
1919                    want,
1920                    "round {round}: literal {needle:?} (cs={cs}) in {text:?}"
1921                );
1922                let as_regex = FindQuery {
1923                    text: regex::escape(&needle),
1924                    case_sensitive: cs,
1925                    whole_word: true,
1926                    regex: true,
1927                };
1928                assert_eq!(
1929                    scan(&text, &as_regex).0,
1930                    want,
1931                    "round {round}: regex {:?} (cs={cs}) in {text:?}",
1932                    as_regex.text
1933                );
1934            }
1935        }
1936        assert!(nonempty > 100, "the oracle must not pass vacuously ({nonempty} non-empty)");
1937    }
1938
1939    /// A **scoped** scan must equal the whole-document scan restricted to the
1940    /// scope — the oracle for find-in-selection. Scanning a sub-range is not the
1941    /// same as filtering the full scan (the greedy non-overlap cursor restarts
1942    /// at the scope's start), so this pins the one case where they must agree:
1943    /// a scope whose start sits on a match boundary. Scope edges are swept
1944    /// across every offset, including mid-char ones, which must clip inward
1945    /// rather than panic.
1946    #[test]
1947    fn scoped_scan_equals_the_whole_text_scan_within_the_scope() {
1948        let corpora = [
1949            "fox fox fox fox fox".to_string(),
1950            "the fox\nreturns the fox to the FOX den\nfoxfoxfox\n".repeat(8),
1951            "ä🦀ab🦀äab日本語ab".repeat(6),
1952        ];
1953        for text in &corpora {
1954            let b = Buffer::new(text).unwrap();
1955            let full = b.text();
1956            for needle in ["a", "ab", "fox", "🦀ä"] {
1957                for cs in [true, false] {
1958                    let query = q(needle, cs);
1959                    let all = scan(&full, &query).0;
1960                    let m = Matcher::compile(&query).expect("a literal compiles");
1961                    for lo in 0..=b.len() {
1962                        for hi in lo..=b.len() {
1963                            // Snap the bound the way the scan does, then take
1964                            // every whole-document match that fits inside it.
1965                            let (slo, shi) =
1966                                (b.clip_offset(lo, Bias::Right), b.clip_offset(hi, Bias::Left));
1967                            if slo > shi {
1968                                continue;
1969                            }
1970                            let expect: Vec<_> = all
1971                                .iter()
1972                                .filter(|m| m.start >= slo && m.end <= shi)
1973                                .cloned()
1974                                .collect();
1975                            let got = scan_buffer(&b, &m, 4096, lo..hi).0;
1976                            // Only compare where the scope opens ON a match
1977                            // boundary; elsewhere a scope can legitimately expose
1978                            // a match the greedy full scan skipped.
1979                            if expect.first().is_none_or(|m| m.start == slo)
1980                                && all.iter().all(|m| m.end <= slo || m.start >= slo)
1981                            {
1982                                assert_eq!(got, expect, "scope {lo}..{hi}, needle {needle:?}");
1983                            }
1984                            // Unconditionally: never escape the scope.
1985                            assert!(
1986                                got.iter().all(|m| m.start >= slo && m.end <= shi),
1987                                "scope {lo}..{hi} leaked a match {got:?}"
1988                            );
1989                        }
1990                    }
1991                }
1992            }
1993        }
1994    }
1995
1996    /// The windowed scan reproduces the cap semantics exactly: capped ⇒ the
1997    /// identical 10k-prefix, and exactly-at-cap ⇒ not capped.
1998    #[test]
1999    fn windowed_scan_caps_like_the_whole_text_scan() {
2000        for extra in [0usize, 3] {
2001            let text = "ab".repeat(FIND_MATCH_CAP + extra);
2002            let b = Buffer::new(&text).unwrap();
2003            let query = q("ab", true);
2004            let m = Matcher::compile(&query).expect("a literal compiles");
2005            assert_eq!(
2006                scan_buffer(&b, &m, 4096, 0..b.len()),
2007                scan(&b.text(), &query),
2008                "extra {extra}"
2009            );
2010        }
2011    }
2012
2013    /// The O(log) [`FindState::navigate`]
2014    /// (`find_count`/`find_rank_before`/`nth_find`) must pick the byte-identical
2015    /// `(id, range)` — and set `active`/`active_start` in lockstep — as a
2016    /// straightforward whole-list walk, over random find sets and random
2017    /// `(head, selection, forward)`: on-match cycling both ways, off-match scan +
2018    /// wrap both ways, and the empty set. Non-find decorations are interleaved as
2019    /// noise that neither path may count.
2020    #[test]
2021    fn navigate_equals_the_full_list_walk() {
2022        // The full-list navigate, retained verbatim as the oracle.
2023        fn navigate_ref(
2024            store: &DecorationStore,
2025            head: u32,
2026            selection: Range<u32>,
2027            forward: bool,
2028        ) -> Option<(DecorationId, Range<u32>)> {
2029            let live: Vec<(DecorationId, Range<u32>)> = store
2030                .decorations_in(0..u32::MAX)
2031                .filter(|r| is_find(r) && r.range.start < r.range.end)
2032                .map(|r| (r.id, r.range.clone()))
2033                .collect();
2034            if live.is_empty() {
2035                return None;
2036            }
2037            let on = live.iter().position(|(_, r)| *r == selection);
2038            let pick = match (on, forward) {
2039                (Some(p), true) => (p + 1) % live.len(),
2040                (Some(p), false) => (p + live.len() - 1) % live.len(),
2041                (None, true) => live.iter().position(|(_, r)| r.start >= head).unwrap_or(0),
2042                (None, false) => {
2043                    live.iter().rposition(|(_, r)| r.start < head).unwrap_or(live.len() - 1)
2044                }
2045            };
2046            Some(live[pick].clone())
2047        }
2048
2049        let mut rng = 0x1234_5678_9ABC_DEF0u64;
2050        let mut next = move || {
2051            rng ^= rng << 13;
2052            rng ^= rng >> 7;
2053            rng ^= rng << 17;
2054            rng
2055        };
2056        for trial in 0..3000u32 {
2057            let mut store = DecorationStore::new();
2058            // Disjoint ascending non-empty finds.
2059            let n = next() % 12;
2060            let mut pos = 0u32;
2061            let mut find_spans: Vec<Range<u32>> = Vec::new();
2062            for _ in 0..n {
2063                pos += (next() % 5) as u32;
2064                let len = 1 + (next() % 4) as u32;
2065                find_spans.push(pos..pos + len);
2066                pos += len;
2067            }
2068            store.add_sorted_batch(
2069                find_spans.iter().cloned(),
2070                DecorationKind::FindMatch,
2071                Stickiness::NeverGrows,
2072            );
2073            // Non-find noise (must be ignored by both paths).
2074            for _ in 0..(next() % 5) {
2075                let s = (next() % u64::from(pos + 1)) as u32;
2076                let l = (next() % 4) as u32;
2077                store.add_decoration(s..s + l, DecorationKind::AutoClosePair, Stickiness::AlwaysGrows);
2078            }
2079            // No persisted find may be empty (FindMatch is EmptyPolicy::Drop);
2080            // the O(1) `find_count` counts all finds, so this must hold for it to
2081            // equal a `start < end` filter.
2082            debug_assert!(
2083                store.decorations_in(0..u32::MAX).filter(is_find).all(|r| r.range.start < r.range.end),
2084                "trial {trial}: an empty FindMatch persisted"
2085            );
2086
2087            let head = (next() % u64::from(pos + 6)) as u32;
2088            // Half the time, sit exactly on a match to exercise on-match cycling.
2089            let selection = if !find_spans.is_empty() && next() % 2 == 0 {
2090                find_spans[(next() as usize) % find_spans.len()].clone()
2091            } else {
2092                let a = (next() % u64::from(pos + 6)) as u32;
2093                let b = a + (next() % 5) as u32;
2094                a..b
2095            };
2096            for forward in [true, false] {
2097                let want = navigate_ref(&store, head, selection.clone(), forward);
2098                let mut state = FindState::new();
2099                let got = state.navigate(head, selection.clone(), &store, forward);
2100                match (&want, &got) {
2101                    (Some((id, range)), Some(got_range)) => {
2102                        assert_eq!(got_range, range, "trial {trial} fwd={forward}: range");
2103                        assert_eq!(state.active, Some(*id), "trial {trial} fwd={forward}: active id");
2104                        assert_eq!(
2105                            state.active_start,
2106                            Some(range.start),
2107                            "trial {trial} fwd={forward}: active_start tracks the pick"
2108                        );
2109                    }
2110                    (None, None) => {
2111                        assert_eq!(state.active, None, "trial {trial} fwd={forward}");
2112                        assert_eq!(state.active_start, None, "trial {trial} fwd={forward}");
2113                    }
2114                    _ => panic!("trial {trial} fwd={forward}: {want:?} vs {got:?}"),
2115                }
2116            }
2117        }
2118    }
2119}