Skip to main content

scrive_core/
find.rs

1//! Find — the search core.
2//!
3//! Search is a **literal substring** with an ASCII case-fold toggle, or — behind
4//! the 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.) The search *bar* is app-side chrome; this module
7//! owns only the match model, the scan, and what a replacement means.
8//!
9//! The one design choice: the match set **lives only in the decoration store**
10//! — the sorted `FindMatch` decorations ARE the match set, in document order
11//! (one fact, one owner). [`FindState`] holds the query and the scan
12//! bookkeeping, never a shadow list of handles. The set is kept current
13//! **eagerly**: every committed transaction (forward edit, undo, redo — they
14//! share the same view-rebase path) runs [`FindState::on_commit`], a windowed
15//! repair around each edit, so consumers never see a stale match. The one
16//! remaining debounced path is the capped-set refill
17//! ([`FindState::maybe_rescan`]).
18//!
19//! Documented, test-pinned relaxation: for needles that cannot overlap
20//! themselves the repaired set is byte-identical to a fresh [`scan`]; a
21//! self-overlapping needle (`"aa"`) repairs to a *maximal valid*
22//! non-overlapping set whose phase near the edit may differ from the greedy
23//! full scan until the next query change.
24
25use core::ops::Range;
26
27use crate::buffer::Buffer;
28use crate::coords::{Bias, Point};
29use crate::decorations::{
30    DecorationId, DecorationKind, DecorationStore, Stickiness, TrackedRange,
31};
32use crate::patch::Patch;
33
34/// An active find query: the pattern plus its option flags.
35///
36/// `PartialEq` so the `Document` surface can no-op an unchanged query without
37/// re-scanning — the flags are part of that identity, because each one changes
38/// *what matches*, not how it is displayed.
39///
40/// The flags pick the matcher: plain text takes the literal byte scan;
41/// `whole_word` or `regex` take the line-scoped engine.
42///
43/// `#[non_exhaustive]` so a future option can be added without a breaking
44/// change: outside this crate, build one with [`FindQuery::new`] (a plain
45/// literal query) and set the options you want on its public fields, rather
46/// than a struct literal.
47#[derive(Clone, Default, PartialEq, Eq, Debug)]
48#[non_exhaustive]
49pub struct FindQuery {
50    /// The text to find — a literal, or a regular expression when
51    /// [`regex`](Self::regex) is set. Empty means "no matches" (never
52    /// match-all).
53    pub text: String,
54    /// When `false`, `a`/`A` fold together. Case folding is ASCII-only in the
55    /// literal matcher, and Unicode-aware in the regex one.
56    pub case_sensitive: bool,
57    /// Match only whole words — the `ab|` option. A match must be bounded by
58    /// non-word characters (or the edges of its line) on both sides.
59    pub whole_word: bool,
60    /// Treat [`text`](Self::text) as a regular expression — the `.*` option.
61    ///
62    /// Matching is **line-scoped**: the engine is fed one line at a time, so a
63    /// pattern can never match across a newline. An invalid
64    /// pattern is not an error state — it is what every prefix of a pattern
65    /// being typed looks like — so it simply yields no matches, and
66    /// [`FindState::pattern_error`] carries the reason for the bar to show.
67    pub regex: bool,
68}
69
70impl FindQuery {
71    /// A plain literal query for `text` — every option off (case-insensitive,
72    /// not whole-word, not regex). The common case; set the public option fields
73    /// on the returned value to change it.
74    #[must_use]
75    pub fn new(text: impl Into<String>) -> Self {
76        Self { text: text.into(), ..Self::default() }
77    }
78}
79
80/// The compiled form of a [`FindQuery`] — the one place that decides *how* a
81/// query matches, so every scan path shares the decision.
82///
83/// # Why two matchers, and why the second is line-scoped
84///
85/// [`FindState::on_commit`]'s windowed repair — the thing that keeps find at
86/// O(edit window) instead of O(document) per keystroke — is built on `k`, the
87/// needle's fixed byte length: a match can start at most `k−1` bytes left of a
88/// changed byte, so that is how far the influence window has to reach. A
89/// **variable-length** pattern destroys that argument outright (`.*` lets an
90/// edit anywhere create a match starting at column 0), and there is no `k` to
91/// widen by.
92///
93/// The fix is to bound matches by something the repair *can* window on: a line.
94/// [`Lines`](Self::Lines) feeds the engine **one line at a time**, so `\n` is
95/// never in the haystack and a pattern therefore *cannot* match across one —
96/// structurally, not by convention (statically deciding whether `\s` or `[^x]`
97/// can match `\n` is fragile). An edit then only creates or destroys matches on
98/// the lines it touches, so the influence window is those lines. Windowed again,
99/// and simpler than the `k−1` dance.
100///
101/// Whole-word rides the same path for a different reason: judging `\bfoo\b`
102/// needs the character *outside* the match, which a byte window's slice does not
103/// contain — but a line always holds its own matches' context, because a word
104/// can never span a newline. So `whole_word` compiles to `\b…\b` and is
105/// line-scoped too, rather than growing a second boundary mechanism.
106///
107/// Plain literal text keeps the fast byte-scan path: it is the common case, it
108/// is what the memchr scan and the `k−1` repair were built for, and nothing
109/// about it needs a line.
110#[derive(Debug)]
111enum Matcher {
112    /// Plain literal text — the memchr byte scan, fixed `k`, `k−1` repair.
113    Literal,
114    /// Whole-word or regex — the line-scoped engine (see the type docs).
115    Lines(regex::Regex),
116}
117
118impl Matcher {
119    /// Compile `query`, or return the pattern error to show the user.
120    ///
121    /// A syntax error is a NORMAL state, not a failure: every prefix of a
122    /// pattern being typed (`(`, `[a-`) is invalid, so callers surface the
123    /// message and match nothing rather than treating it as broken.
124    fn compile(query: &FindQuery) -> Result<Self, String> {
125        if !query.whole_word && !query.regex {
126            return Ok(Self::Literal);
127        }
128        // A whole-word literal is `\b` + the escaped text + `\b`; a whole-word
129        // REGEX wraps the user's pattern in a non-capturing group first, so
130        // `foo|bar` means `\b(?:foo|bar)\b` and not `\bfoo|bar\b`.
131        let body =
132            if query.regex { format!("(?:{})", query.text) } else { regex::escape(&query.text) };
133        let pattern = if query.whole_word { format!(r"\b{body}\b") } else { body };
134        regex::RegexBuilder::new(&pattern)
135            .case_insensitive(!query.case_sensitive)
136            // The haystack is one line, so `.` never sees a `\n` anyway; this
137            // just makes `$`/`^` mean the line's edges, which is what a
138            // line-scoped find should mean.
139            .multi_line(true)
140            .build()
141            .map(Self::Lines)
142            .map_err(|e| e.to_string())
143    }
144
145    /// Whether this matcher is bounded to single lines — the flag every scan and
146    /// repair path branches on.
147    fn is_line_scoped(&self) -> bool {
148        matches!(self, Self::Lines(_))
149    }
150}
151
152/// Debounce window (ms) for the capped-set refill — a user-facing default,
153/// exported as a `pub fn`. This is the ONLY debounced find path: ordinary edits
154/// repair the match set eagerly at the commit.
155#[must_use]
156pub fn default_find_debounce() -> u64 {
157    100
158}
159
160/// The scan stops after this many matches and records `capped` — cheap
161/// insurance against a pathological query. A capped set is a *prefix of the
162/// document*: everything up to [`FindState`]'s coverage frontier is exact,
163/// nothing beyond it is represented.
164pub const FIND_MATCH_CAP: usize = 10_000;
165
166/// Every non-overlapping match of `query` in `text`, leftmost-first, as byte
167/// spans; the `bool` is whether the scan hit [`FIND_MATCH_CAP`].
168///
169/// The query's options pick the matcher. Plain text takes a **literal byte-wise
170/// substring** scan — the next probe starts at the previous match's end
171/// (non-overlapping), case fold is **ASCII only**, byte-wise is exact for the
172/// ASCII DSL, and a multi-byte needle is out of scope. `whole_word` / `regex`
173/// take the **line-scoped** engine, fed one line at a time so a match never
174/// crosses a newline. An empty literal query yields no matches (never
175/// match-all); an unfinished regex yields none until it parses.
176///
177/// The pure whole-text scan the tests use as the oracle. The live paths (the
178/// query-change full scan, the capped refill, the per-edit window repairs) share
179/// its matcher rather than calling it directly, so the match rules cannot fork.
180/// Case-sensitive literal search rides `memchr::memmem`; the fold path probes
181/// the needle's first byte's two case forms via `memchr2` — both bit-identical
182/// to the naive reference scan (pinned by `scan_equals_the_naive_oracle`).
183#[must_use]
184pub fn scan(text: &str, query: &FindQuery) -> (Vec<Range<u32>>, bool) {
185    scan_capped(text, query, FIND_MATCH_CAP)
186}
187
188/// [`scan`] with the cap injected. Compiles the query's matcher, then defers to
189/// [`scan_with`] — the same per-slice matcher the windowed paths use — so the
190/// text you replace cannot diverge from the text you were shown.
191fn scan_capped(text: &str, query: &FindQuery, cap: usize) -> (Vec<Range<u32>>, bool) {
192    match Matcher::compile(query) {
193        Ok(m) => scan_with(text, query, &m, cap),
194        // An unfinished pattern (`(`, `[a-`) matches nothing. Not an error: it
195        // is what every prefix of a pattern being typed looks like.
196        Err(_) => (Vec::new(), false),
197    }
198}
199
200/// [`scan_capped`] against an ALREADY-compiled matcher, so a windowed scan
201/// compiles the pattern once rather than once per window.
202fn scan_with(text: &str, query: &FindQuery, m: &Matcher, cap: usize) -> (Vec<Range<u32>>, bool) {
203    match m {
204        Matcher::Literal => scan_literal(text, query, cap),
205        Matcher::Lines(re) => scan_lines(text, re, cap),
206    }
207}
208
209/// The line-scoped scan: the engine is fed one line at a time — **without its
210/// newline** — so a pattern can never match across one. Offsets are relative to
211/// `text`.
212fn scan_lines(text: &str, re: &regex::Regex, cap: usize) -> (Vec<Range<u32>>, bool) {
213    let mut spans: Vec<Range<u32>> = Vec::new();
214    let mut base = 0usize;
215    for line in text.split_inclusive('\n') {
216        // Strip the `\n` before matching: keeping it in the haystack is exactly
217        // what would let `\s` or `[^x]` slip across a line and break the
218        // line-windowed repair this design rests on.
219        let body = line.strip_suffix('\n').unwrap_or(line);
220        for m in re.find_iter(body) {
221            // Zero-width hits (`a*` against "bbb") are dropped: there is nothing
222            // to navigate to, highlight, or replace, and the store's
223            // `EmptyPolicy::Drop` would discard them anyway — so the count stays
224            // honest about what the user can act on.
225            if m.start() >= m.end() {
226                continue;
227            }
228            if spans.len() == cap {
229                return (spans, true); // cap reached
230            }
231            spans.push((base + m.start()) as u32..(base + m.end()) as u32);
232        }
233        base += line.len();
234    }
235    (spans, false)
236}
237
238/// The literal byte scan — the fast common path (memchr, fixed `k`).
239fn scan_literal(text: &str, query: &FindQuery, cap: usize) -> (Vec<Range<u32>>, bool) {
240    let needle = query.text.as_bytes();
241    if needle.is_empty() {
242        return (Vec::new(), false); // empty needle = zero matches
243    }
244    let hay = text.as_bytes();
245    let mut spans = Vec::new();
246    if query.case_sensitive {
247        // memmem yields non-overlapping occurrences leftmost-first — the next
248        // probe starts at the previous match's end.
249        for i in memchr::memmem::find_iter(hay, needle) {
250            if spans.len() == cap {
251                return (spans, true); // cap reached
252            }
253            spans.push(i as u32..(i + needle.len()) as u32);
254        }
255    } else {
256        let (lo, up) = (needle[0].to_ascii_lowercase(), needle[0].to_ascii_uppercase());
257        let mut i = 0usize;
258        while i + needle.len() <= hay.len() {
259            // Jump to the next candidate first byte (either case form)…
260            let Some(j) = memchr::memchr2(lo, up, &hay[i..]) else {
261                break;
262            };
263            let c = i + j;
264            if c + needle.len() > hay.len() {
265                break;
266            }
267            // …and verify the window byte-wise (ASCII-only fold).
268            if hay[c..c + needle.len()].eq_ignore_ascii_case(needle) {
269                if spans.len() == cap {
270                    return (spans, true); // cap reached
271                }
272                spans.push(c as u32..(c + needle.len()) as u32);
273                i = c + needle.len(); // non-overlapping: resume past this match
274            } else {
275                i = c + 1;
276            }
277        }
278    }
279    (spans, false)
280}
281
282/// [`scan`] driven through the buffer's backing-agnostic ranged reads:
283/// fixed-size windows with a needle-length−1 overlap, so a match straddling a
284/// window boundary is found by the next window and the result is
285/// **byte-identical to `scan(&buffer.text(), query)`** (pinned by
286/// `windowed_scan_equals_the_whole_text_scan`). Never materializes the
287/// document — peak transient is one window — and a capped dense query stops
288/// after O(bytes-until-cap), which `buffer.text()` would defeat by copying
289/// the whole rope up front.
290fn scan_buffer(
291    buffer: &Buffer,
292    query: &FindQuery,
293    window: u32,
294    within: Range<u32>,
295) -> (Vec<Range<u32>>, bool) {
296    scan_buffer_capped(buffer, query, window, FIND_MATCH_CAP, within)
297}
298
299/// Every match of `query` inside `within` — the whole document when find is
300/// unscoped, or the find-in-selection scope — **uncapped**. The replace-all scan.
301///
302/// Replace-all cannot read the live match set: that set is capped at
303/// [`FIND_MATCH_CAP`] and is only a *prefix of its range*, so "all" built from
304/// it would silently stop at the cap and leave the tail untouched. It runs the
305/// same windowed [`scan_buffer_capped`] the display scan does — one match rule,
306/// no fork — with the cap lifted. Whole-`within` in the worst case (you cannot
307/// replace what you have not found), which is why it is reachable only from a
308/// discrete user action, never a keystroke.
309pub(crate) fn scan_all(buffer: &Buffer, query: &FindQuery, within: Range<u32>) -> Vec<Range<u32>> {
310    scan_buffer_capped(buffer, query, crate::buffer::SCAN_WINDOW, usize::MAX, within).0
311}
312
313/// [`scan_buffer`] with the cap injected — see [`scan_capped`].
314///
315/// `within` bounds the scan (the whole document, or the find-in-selection
316/// scope). It is clamped to the document and snapped INWARD to char boundaries,
317/// so a caller-supplied scope can be any offsets without breaking `slice`'s
318/// str contract.
319fn scan_buffer_capped(
320    buffer: &Buffer,
321    query: &FindQuery,
322    window: u32,
323    cap: usize,
324    within: Range<u32>,
325) -> (Vec<Range<u32>>, bool) {
326    let Ok(m) = Matcher::compile(query) else {
327        return (Vec::new(), false); // an unfinished pattern matches nothing
328    };
329    // Snap INWARD: `hi` left, `lo` right, so the scanned span never grows past
330    // the requested bound and never splits a char.
331    let hi = buffer.clip_offset(within.end.min(buffer.len()), Bias::Left);
332    let lo = buffer.clip_offset(within.start.min(hi), Bias::Right);
333    if let Matcher::Lines(re) = &m {
334        return scan_buffer_lines(buffer, re, cap, lo..hi);
335    }
336    let k = query.text.len() as u64;
337    if k == 0 {
338        return (Vec::new(), false); // empty needle = zero matches
339    }
340    // ≥ 2k, so every iteration provably advances even for giant needles.
341    let window = u64::from(window).max(k * 2);
342    let mut spans: Vec<Range<u32>> = Vec::new();
343    let mut pos: u32 = lo; // the non-overlapping scan cursor (next probe start)
344    while u64::from(pos) + k <= u64::from(hi) {
345        // The window end snaps RIGHT to a char boundary: `slice` stays on the
346        // str-slicing contract, the window is never empty, and growing a
347        // window never loses a candidate.
348        let win_end = buffer
349            .clip_offset((u64::from(pos) + window).min(u64::from(hi)) as u32, Bias::Right);
350        let (local, local_capped) = scan_with(&buffer.slice(pos..win_end), query, &m, cap);
351        let found = !local.is_empty();
352        for r in local {
353            if spans.len() == cap {
354                return (spans, true); // cap reached
355            }
356            spans.push(pos + r.start..pos + r.end);
357        }
358        // A capped in-window scan left the window's tail unscanned — keep
359        // going from the last match even at the scan's end (the overflow
360        // match it found still has to trip the cap above).
361        if win_end == hi && !local_capped {
362            break;
363        }
364        // Resume through the one owner of the seam logic ([`Buffer::scan_resume`]):
365        // past the last match when the window found one (the non-overlap cursor —
366        // the window's tail may be re-scanned but is never skipped), else at the
367        // char boundary before `win_end − (k−1)`.
368        let last_end = found.then(|| spans.last().expect("found ⇒ spans non-empty").end);
369        pos = buffer.scan_resume(pos, win_end, k as u32, last_end);
370    }
371    (spans, false)
372}
373
374/// The line-scoped windowed scan over a buffer, in absolute offsets.
375///
376/// Whole lines, **no overlap**: a match cannot cross `\n`, so a line boundary
377/// can never fall inside one and the literal path's `k−1` seam logic has nothing
378/// to do here.
379///
380/// Boundaries are judged against the WHOLE line even when `within` opens or
381/// closes mid-line — a scope edge must never fake a word boundary — so matches
382/// are filtered to `within` afterwards rather than by truncating the haystack.
383fn scan_buffer_lines(
384    buffer: &Buffer,
385    re: &regex::Regex,
386    cap: usize,
387    within: Range<u32>,
388) -> (Vec<Range<u32>>, bool) {
389    let mut spans: Vec<Range<u32>> = Vec::new();
390    if within.start >= within.end {
391        return (spans, false);
392    }
393    let first = buffer.offset_to_point(within.start).row;
394    let last = buffer.offset_to_point(within.end).row;
395    for row in first..=last {
396        let base = buffer.point_to_offset(Point::new(row, 0));
397        let line = buffer.line(row); // excludes the trailing `\n`
398        for m in re.find_iter(&line) {
399            if m.start() >= m.end() {
400                continue; // zero-width: nothing to act on
401            }
402            let (s, e) = (base + m.start() as u32, base + m.end() as u32);
403            if s < within.start || e > within.end {
404                continue; // outside the scope, which may open/close mid-line
405            }
406            if spans.len() == cap {
407                return (spans, true); // cap reached
408            }
409            spans.push(s..e);
410        }
411    }
412    (spans, false)
413}
414
415/// The whole lines a (post-edit) byte range touches — the line-scoped repair's
416/// influence window.
417///
418/// This is the line-scoped answer to the literal path's `±(k−1)`: a match cannot
419/// cross a `\n`, so an edit can only have created or destroyed matches on the
420/// lines it touched, and re-scanning exactly those lines is both necessary and
421/// sufficient.
422fn whole_lines(buffer: &Buffer, r: &Range<u32>) -> Range<u32> {
423    let first = buffer.offset_to_point(r.start).row;
424    let last = buffer.offset_to_point(r.end).row;
425    let start = buffer.point_to_offset(Point::new(first, 0));
426    // The last touched line's CONTENT end — deliberately NOT the next line's
427    // start. `take_matching_in` removes everything *touching* the window, so a
428    // window reaching the next line's first byte would carry off a match that
429    // starts there, while the re-scan (bounded by this same range) would refuse
430    // to re-add it as out of range — silently losing it. Ending before the `\n`
431    // makes the window unable to touch anything off its own lines.
432    let base = buffer.point_to_offset(Point::new(last, 0));
433    start..base + buffer.line(last).len() as u32
434}
435
436/// Every match in `within`, paired with the text that should replace it — the
437/// replace-all plan, and the one place that decides what a replacement *means*.
438///
439/// In **regex** mode `replacement` is a TEMPLATE: `$1` / `${name}` expand
440/// against each match's own capture groups, as VS Code does. In every other mode
441/// (literal, whole-word) it is literal text — a `$1` typed into the replace box
442/// replaces with a literal `$1`, because nothing captured anything.
443///
444/// When `preserve_case` is set (the replace bar's `AB` toggle), each
445/// replacement is re-cased to the pattern of the text it replaces — an
446/// ALL-CAPS match takes an upper-cased replacement, a Capitalized match a
447/// capitalized one — so a mechanical rename does not flatten the casing of the
448/// identifiers it touches. See [`preserve_case_of`].
449pub(crate) fn replacements(
450    buffer: &Buffer,
451    query: &FindQuery,
452    within: Range<u32>,
453    replacement: &str,
454    preserve_case: bool,
455) -> Vec<(Range<u32>, String)> {
456    if !query.regex {
457        // Literal and whole-word: one fixed string, every match — re-cased to
458        // the match when `preserve_case` is on.
459        return scan_all(buffer, query, within)
460            .into_iter()
461            .map(|r| {
462                let text = if preserve_case {
463                    preserve_case_of(&buffer.slice(r.clone()), replacement)
464                } else {
465                    replacement.to_string()
466                };
467                (r, text)
468            })
469            .collect();
470    }
471    let Ok(Matcher::Lines(re)) = Matcher::compile(query) else {
472        return Vec::new(); // an unfinished pattern replaces nothing
473    };
474    // Mirrors `scan_buffer_lines` exactly — same lines, same filters — but walks
475    // captures instead of matches, because expansion needs the groups the plain
476    // scan throws away.
477    let mut out = Vec::new();
478    if within.start >= within.end {
479        return out;
480    }
481    let first = buffer.offset_to_point(within.start).row;
482    let last = buffer.offset_to_point(within.end).row;
483    for row in first..=last {
484        let base = buffer.point_to_offset(Point::new(row, 0));
485        let line = buffer.line(row); // excludes the trailing `\n`
486        for c in re.captures_iter(&line) {
487            let m = c.get(0).expect("capture group 0 is the whole match");
488            if m.start() >= m.end() {
489                continue; // zero-width: nothing to replace
490            }
491            let (s, e) = (base + m.start() as u32, base + m.end() as u32);
492            if s < within.start || e > within.end {
493                continue; // outside the scope, which may open/close mid-line
494            }
495            let mut dst = String::new();
496            c.expand(replacement, &mut dst);
497            // Preserve-case keys off the WHOLE match (group 0), applied AFTER
498            // template expansion — so `$1`-driven text is re-cased too.
499            let text = if preserve_case { preserve_case_of(m.as_str(), &dst) } else { dst };
500            out.push((s..e, text));
501        }
502    }
503    out
504}
505
506/// Re-case `replacement` to the case *pattern* of `matched` — the engine behind
507/// the replace bar's preserve-case (`AB`) toggle.
508///
509/// Three patterns carry over, matching VS Code: an all-upper match ⇒ the
510/// replacement upper-cased; an all-lower match ⇒ lower-cased; a match whose
511/// first cased letter is upper (a Capitalized / Title word) ⇒ the replacement's
512/// first letter upper-cased, the rest left as typed. Anything else (mixed case
513/// like `camelCase`, or no cased letters at all) leaves the replacement exactly
514/// as written — guessing at mixed case does more harm than good.
515fn preserve_case_of(matched: &str, replacement: &str) -> String {
516    let mut has_upper = false;
517    let mut has_lower = false;
518    let mut first_is_upper = None; // set by the first CASED char only
519    for ch in matched.chars() {
520        if ch.is_uppercase() {
521            has_upper = true;
522            first_is_upper.get_or_insert(true);
523        } else if ch.is_lowercase() {
524            has_lower = true;
525            first_is_upper.get_or_insert(false);
526        }
527    }
528    match (has_upper, has_lower) {
529        (true, false) => replacement.to_uppercase(),
530        (false, true) => replacement.to_lowercase(),
531        // Capitalized/Title: upper the first letter, leave the rest as typed.
532        (true, true) if first_is_upper == Some(true) => {
533            let mut chars = replacement.chars();
534            match chars.next() {
535                Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
536                None => String::new(),
537            }
538        }
539        _ => replacement.to_string(),
540    }
541}
542
543/// Whether a tracked range is a find match — the one predicate the store
544/// queries and batch removals share.
545fn is_find(r: &TrackedRange) -> bool {
546    matches!(r.kind, DecorationKind::FindMatch)
547}
548
549/// The search view-state: the active query, the active match's decoration id,
550/// and the coverage/cap bookkeeping.
551///
552/// The one design rule: **the store IS the match set** — the sorted `FindMatch`
553/// decorations, in `(start, id)` = document order. This state deliberately
554/// holds no handle list; count, order, and N-of-M all derive from store queries
555/// (one fact, one owner). The set is repaired eagerly per commit via
556/// [`FindState::on_commit`], so it is always current — undo/redo inherit the
557/// repair through the shared view-rebase mover.
558#[derive(Debug, Default)]
559pub struct FindState {
560    query: Option<FindQuery>,
561    /// The decoration id of the highlighted/navigated match, if any.
562    active: Option<DecorationId>,
563    /// The active match's tracked START offset — the position half of the active
564    /// handle, kept in lockstep with [`active`](Self::active) (both `Some` or
565    /// both `None`). It rides each commit's patch in
566    /// [`on_commit`](Self::on_commit) with **`Bias::Right`** — the start bias of
567    /// a `FindMatch`'s `NeverGrows` stickiness — so it stays equal to the active
568    /// decoration's current start (pinned by `active_start_tracks…`). It earns
569    /// its place as the O(log) index that lets the per-commit presence check and
570    /// the N-of-M ordinal avoid an O(M) whole-store walk, not as a display
571    /// convenience.
572    active_start: Option<u32>,
573    /// Whether the match set is a capped *prefix* of the document.
574    capped: bool,
575    /// Byte offset the scan has covered: `buffer.len()` when `!capped`, else
576    /// the last kept match's end. Rides each commit's patch (`Bias::Left`);
577    /// while capped, repairs clamp their windows to it and edits beyond it are
578    /// the refill's job.
579    coverage_end: u32,
580    /// While capped: the live count has dropped below the cap, so coverage
581    /// could grow — the ONLY condition under which [`FindState::maybe_rescan`]
582    /// re-scans. (At exactly the cap the set provably equals the ideal capped
583    /// prefix: repairs keep the covered prefix exact, and its first
584    /// [`FIND_MATCH_CAP`] matches are the document's first ones.)
585    capped_stale: bool,
586    /// `now_ms` of the last full scan — the capped-refill debounce anchor.
587    last_scan_ms: u64,
588    /// **Find in selection**: when `Some`, matches exist ONLY inside this byte
589    /// range — the scan is restricted to it and every repair window clamps to
590    /// it, exactly as they already clamp to [`coverage_end`](Self::coverage_end).
591    ///
592    /// A position like any other, so it rides each commit's patch in
593    /// [`on_commit`](Self::on_commit) rather than being re-derived: biases
594    /// `(Left, Right)` — the `AlwaysGrows` pair — so text typed at either edge
595    /// lands *inside* the scope. Collapsing to empty drops it (the
596    /// [`EmptyPolicy::Drop`] rule a `FindMatch` follows), because a zero-width
597    /// scope can only mean "no matches ever" and silently searching nothing is
598    /// worse than searching everything.
599    ///
600    /// [`EmptyPolicy::Drop`]: crate::decorations::EmptyPolicy::Drop
601    scope: Option<Range<u32>>,
602    /// `query` compiled — cached so a keystroke's repair never recompiles the
603    /// pattern. Kept in lockstep with [`query`](Self::query) (both are set only
604    /// in [`set_query`](Self::set_query)); `None` iff there is no query or the
605    /// pattern does not parse.
606    matcher: Option<Matcher>,
607    /// Why the pattern does not parse, when `regex` is on and it does not.
608    ///
609    /// Not a failure mode: every prefix of a pattern being typed (`(`, `[a-`) is
610    /// invalid, so this is state to SHOW — matching yields nothing meanwhile and
611    /// resumes the moment the pattern parses again.
612    pattern_error: Option<String>,
613}
614
615impl FindState {
616    /// An empty state — no query, no matches.
617    #[must_use]
618    pub fn new() -> Self {
619        Self::default()
620    }
621
622    /// The active query, or `None` if find is idle.
623    #[must_use]
624    pub fn query(&self) -> Option<&FindQuery> {
625        self.query.as_ref()
626    }
627
628    /// How many live matches the current query has — the store IS the match set,
629    /// so this is the O(1) root-summary [`find_count`] read.
630    ///
631    /// [`find_count`]: DecorationStore::find_count
632    #[must_use]
633    pub fn match_count(&self, store: &DecorationStore) -> usize {
634        store.find_count()
635    }
636
637    /// The decoration handle of the active match, if any — the render layer
638    /// compares each `FindMatch` decoration to this to pick the distinct style.
639    #[must_use]
640    pub fn active_id(&self) -> Option<DecorationId> {
641        self.active
642    }
643
644    /// The active match's tracked start offset — the position half of the active
645    /// handle, in lockstep with [`active_id`](Self::active_id). `Document` reads
646    /// it for the O(log) N-of-M ordinal (`active_find_match`).
647    #[must_use]
648    pub(crate) fn active_start(&self) -> Option<u32> {
649        self.active_start
650    }
651
652    /// The active match's live range — `None` when no match is active, or when
653    /// the tracked start no longer carries it.
654    ///
655    /// O(log M): the tracked start's rank, then that rank's span. Deliberately
656    /// NOT [`DecorationStore::decoration_range`], which is documented as an
657    /// O(store) cold path — keeping [`active_start`](Self::active_start) in
658    /// lockstep exists precisely so the active handle resolves without a
659    /// whole-store walk.
660    #[must_use]
661    pub(crate) fn active_range(&self, store: &DecorationStore) -> Option<Range<u32>> {
662        let (id, start) = (self.active?, self.active_start?);
663        // Finds are disjoint, so only the find starting exactly at `start` can be
664        // the active one — the same identity `navigate` uses to test "on a match".
665        let (found, range) = store.nth_find(store.find_rank_before(start))?;
666        (found == id).then_some(range)
667    }
668
669    /// Whether the match set is a capped prefix of the document
670    /// ([`FIND_MATCH_CAP`]).
671    #[must_use]
672    pub fn capped(&self) -> bool {
673        self.capped
674    }
675
676    /// The find-in-selection scope — matches exist only inside it. `None` means
677    /// the whole document.
678    #[must_use]
679    pub fn scope(&self) -> Option<Range<u32>> {
680        self.scope.clone()
681    }
682
683    /// Set (or clear with `None`) the find-in-selection scope, re-scanning
684    /// synchronously — the scope is part of what matches, so changing it is a
685    /// query change, not a display filter.
686    ///
687    /// An empty range clears the scope rather than pinning zero matches
688    /// forever: searching everything is a recoverable surprise, silently
689    /// searching nothing is not. A scope equal to the current one is a no-op.
690    pub fn set_scope(
691        &mut self,
692        scope: Option<Range<u32>>,
693        buffer: &Buffer,
694        store: &mut DecorationStore,
695        now_ms: u64,
696    ) {
697        let scope = scope.filter(|s| s.start < s.end);
698        if scope == self.scope {
699            return;
700        }
701        self.scope = scope;
702        self.rescan(buffer, store, now_ms);
703    }
704
705    /// Set (or clear with `None`) the query and scan synchronously; never
706    /// scrolls, and clears the active match. A query equal to the current one is
707    /// a no-op (no needless re-scan).
708    ///
709    /// The full scan runs `scan_buffer` — whole-document in coverage but
710    /// windowed in memory, and a capped dense query stops at
711    /// O(bytes-until-cap) instead of copying the rope's tail.
712    pub fn set_query(
713        &mut self,
714        query: Option<FindQuery>,
715        buffer: &Buffer,
716        store: &mut DecorationStore,
717        now_ms: u64,
718    ) {
719        if query == self.query {
720            return;
721        }
722        self.query = query;
723        // Compile ONCE here, in lockstep with the query — the per-keystroke
724        // repair reads this cache rather than re-parsing the pattern.
725        (self.matcher, self.pattern_error) = match &self.query {
726            None => (None, None),
727            Some(q) => match Matcher::compile(q) {
728                Ok(m) => (Some(m), None),
729                // An unfinished pattern is a normal state: keep the reason to
730                // show, match nothing until it parses.
731                Err(e) => (None, Some(e)),
732            },
733        };
734        self.active = None; // a query change drops the active match
735        self.active_start = None; // …and its tracked position (lockstep)
736        self.rescan(buffer, store, now_ms);
737    }
738
739    /// Why the current pattern does not parse, if it does not — for the find bar
740    /// to show. `None` whenever the query is matching normally (including when
741    /// it simply has no hits).
742    #[must_use]
743    pub fn pattern_error(&self) -> Option<&str> {
744        self.pattern_error.as_deref()
745    }
746
747    /// Refill a **capped** match set: re-scan iff a query is active, the set is
748    /// capped with room below the cap, and the debounce window has elapsed.
749    /// `now_ms` is an injected monotonic clock — the widget passes real time,
750    /// the headless suite a fake. Returns whether it scanned.
751    ///
752    /// Because the eager per-commit repair keeps the set current, the only job
753    /// here is growing a capped set's coverage after matches inside it died.
754    pub fn maybe_rescan(&mut self, buffer: &Buffer, store: &mut DecorationStore, now_ms: u64) -> bool {
755        if self.query.is_none()
756            || !(self.capped && self.capped_stale)
757            || now_ms.saturating_sub(self.last_scan_ms) < default_find_debounce()
758        {
759            return false;
760        }
761        self.rescan(buffer, store, now_ms);
762        true
763    }
764
765    /// Repair the match set around a committed patch — the mover hook called
766    /// from the view-rebase path AFTER `DecorationStore::apply_patch` (it needs
767    /// post-patch positions). No-op when find is idle.
768    ///
769    /// The influence window depends on the matcher. A **literal** query (post-edit
770    /// coordinates, `k` = needle byte length): every affected placement must
771    /// overlap `new.start−(k−1) .. new.end+(k−1)` (a created/destroyed match
772    /// contains a changed byte or the join point). A **line-scoped** query
773    /// (whole-word / regex) instead repairs the whole lines the edit touched —
774    /// a match cannot cross a newline, so nothing off those lines can have
775    /// changed — with no `k−1` widening and no anchor/guard (the greedy phase the
776    /// widening repairs does not exist there). The rest of this describes the
777    /// literal path. Matches touching the window are removed
778    /// wholesale ([`DecorationStore::take_matching_in`]); the re-scan zone is
779    /// then the window widened (a) to the removed extents — a merely-touching
780    /// match must be re-findable, not lost — and (b) a further `k−1` bytes each
781    /// way, because a placement overlapping the removal zone (one whose old
782    /// greedy blocker just died — the self-overlap phase repair) can start up
783    /// to `k−1` bytes left of it and end as far right. Two clamps keep the
784    /// zone from double-owning text: the scan starts at the last surviving
785    /// match ending in the left margin (the anchor), and candidates
786    /// overlapping the first surviving match right of the zone are dropped
787    /// (the guard). The zone is greedily re-scanned with the same pure
788    /// [`scan`] and batch-reinserted. Matches outside every zone keep their
789    /// decoration ids (id stability is what lets the active match survive
790    /// unrelated edits).
791    ///
792    /// Active survival: untouched active stays (same id); an active removed by a
793    /// window transfers to a re-created match at its exact post-patch start;
794    /// otherwise it clears.
795    ///
796    /// Cap: `coverage_end` rides the patch first; windows clamp to it, so
797    /// repairs beyond a capped set's coverage are skipped (the refill's job).
798    /// If a repair pushes the live count past [`FIND_MATCH_CAP`], the tail is
799    /// trimmed so the set stays a prefix of the document.
800    pub fn on_commit(&mut self, patch: &Patch, buffer: &Buffer, store: &mut DecorationStore) {
801        let Some(query) = &self.query else { return };
802        // No matcher ⇒ the pattern does not parse ⇒ there are no matches to
803        // repair (the set is already empty, and stays so until it parses).
804        let Some(matcher) = &self.matcher else { return };
805        let line_scoped = matcher.is_line_scoped();
806        let k = query.text.len() as u32;
807        if patch.is_empty() || (!line_scoped && k == 0) {
808            return; // an empty literal needle has no matches to repair
809        }
810        // Coverage rides the patch ONCE, before the per-edit loop; an uncapped
811        // set always covers the whole (post-edit) document.
812        self.coverage_end = if self.capped {
813            patch.map_offset(self.coverage_end, Bias::Left)
814        } else {
815            buffer.len()
816        };
817        // The active handle's position rides the SAME committed patch as the
818        // decoration it tracks — `Bias::Right`, a FindMatch's `NeverGrows` start
819        // bias — so it stays equal to that decoration's post-patch start.
820        self.active_start = self.active_start.map(|s| patch.map_offset(s, Bias::Right));
821        // The scope is a derived position like any other, so it rides the SAME
822        // committed patch rather than being re-derived — `Bias::Left`/`Right`
823        // (the `AlwaysGrows` pair) so text typed at either edge lands inside it.
824        // Collapsed ⇒ dropped: a zero-width scope can only match nothing, and
825        // silently searching nothing is worse than searching everything.
826        self.scope = self.scope.take().and_then(|s| {
827            let lo = patch.map_offset(s.start, Bias::Left);
828            let hi = patch.map_offset(s.end, Bias::Right);
829            (lo < hi).then_some(lo..hi)
830        });
831        // Every repair window lives inside the covered prefix AND, when find is
832        // scoped, inside the scope — matches cannot exist outside either, so an
833        // edit out there repairs nothing and one straddling an edge repairs only
834        // the inside. This is the same mechanism as the capped-coverage clamp,
835        // with one more bound; both are applied AFTER the char-boundary snap, so
836        // the snap can never carry a window back outside its bound.
837        let (lo_bound, hi_bound) = match &self.scope {
838            Some(s) => (s.start, s.end.min(self.coverage_end)),
839            None => (0, self.coverage_end),
840        };
841        let mut removed_active_start: Option<u32> = None;
842        for e in patch.edits() {
843            // The influence window, clamped to the covered prefix (and the
844            // scope). Literal: ±(k−1) bytes, snapped OUTWARD to char boundaries
845            // — a fixed-length match can start at most k−1 bytes left of a
846            // changed byte. Line-scoped: the WHOLE lines the edit touches, which
847            // is the same argument in the only currency a variable-length
848            // pattern has — a match cannot cross `\n`, so nothing off those
849            // lines can have been created or destroyed.
850            let (w_start, w_end) = if line_scoped {
851                let lines = whole_lines(buffer, &e.new);
852                (lines.start, lines.end)
853            } else {
854                let s = buffer.clip_offset(e.new.start.saturating_sub(k - 1), Bias::Left);
855                let t_raw = e.new.end.saturating_add(k - 1).min(hi_bound);
856                (s, buffer.clip_offset(t_raw, Bias::Right))
857            };
858            let (w_start, w_end) = (w_start.max(lo_bound), w_end.min(hi_bound));
859            if w_start > w_end {
860                continue; // entirely outside the coverage/scope: not our repair
861            }
862            let removed = store.take_matching_in(w_start..w_end, is_find);
863            if let Some(active) = self.active {
864                if let Some(r) = removed.iter().find(|r| r.id == active) {
865                    removed_active_start = Some(r.range.start);
866                    self.active = None;
867                    self.active_start = None; // lockstep; the transfer below re-sets both
868                }
869            }
870            // Line-scoped: lines are independent, so there is no greedy phase to
871            // repair and no match can straddle the window's edge (it would have
872            // to cross a `\n`). The re-scan zone IS the window, and the anchor
873            // and guard below have nothing to do — the whole `k−1` widening
874            // dance exists only to serve a fixed-length needle's greedy cursor.
875            let (scan_lo, scan_hi, guard) = if line_scoped {
876                (w_start, w_end, None)
877            } else {
878            // Widen to the removed extents: a match that merely touched the
879            // window (start left of it / end right of it) must be re-findable,
880            // or removal would lose it.
881            let ext_lo =
882                removed.iter().map(|r| r.range.start).min().map_or(w_start, |s| s.min(w_start));
883            let ext_hi =
884                removed.iter().map(|r| r.range.end).max().map_or(w_end, |e| e.max(w_end));
885            // Widen a further k−1 each way: a placement overlapping the
886            // removal zone — one whose old greedy blocker died (self-overlap
887            // phase) — can start up to k−1 bytes left of `ext_lo` and end as
888            // far right of `ext_hi`. Removed-match boundaries are char
889            // boundaries (byte-wise hits of a valid-UTF-8 needle); the ±(k−1)
890            // arithmetic can unsnap, so clip again.
891            let scan_lo =
892                buffer.clip_offset(ext_lo.saturating_sub(k - 1), Bias::Left).max(lo_bound);
893            let scan_hi = buffer
894                .clip_offset(ext_hi.saturating_add(k - 1).min(hi_bound), Bias::Right)
895                .min(hi_bound);
896            // Left anchor: the widened margin may hold a surviving match —
897            // start scanning at its end so its text is never double-owned.
898            // (Survivors intersecting `scan_lo..ext_lo` provably end at or
899            // before `ext_lo`: anything reaching further would have touched
900            // the window or a removed extent and been removed itself.)
901            let anchor = store
902                .decorations_in(scan_lo..ext_lo)
903                .filter(is_find)
904                .map(|r| r.range.end)
905                .max();
906            let scan_lo = anchor.map_or(scan_lo, |a| a.max(scan_lo));
907            // Right guard: candidates may not overlap the first surviving
908            // match right of the zone (survivors there provably start at or
909            // after `ext_hi`, same argument as the anchor).
910            let guard = store
911                .decorations_in(ext_hi..scan_hi)
912                .filter(|r| is_find(r) && r.range.start >= ext_hi)
913                .map(|r| r.range.start)
914                .min();
915                (scan_lo, scan_hi, guard)
916            };
917            // Re-scan the zone through the SAME cached matcher the display scan
918            // runs, so a repaired match cannot differ from a freshly found one.
919            let mut spans: Vec<Range<u32>> = match matcher {
920                Matcher::Lines(re) => {
921                    scan_buffer_lines(buffer, re, FIND_MATCH_CAP, scan_lo..scan_hi).0
922                }
923                Matcher::Literal => scan_literal(
924                    &buffer.slice(scan_lo..scan_hi),
925                    query,
926                    FIND_MATCH_CAP,
927                )
928                .0
929                .into_iter()
930                .map(|r| scan_lo + r.start..scan_lo + r.end)
931                .collect(),
932            };
933            if let Some(guard) = guard {
934                spans.retain(|s| s.end <= guard);
935            }
936            // Windowed insert: the batch merges into its ≤band without the
937            // O(M log M) whole-store `to_vec()`+re-sort of `add_sorted_batch`.
938            // Byte-identical to that here (the window is already cleared of
939            // finds), pinned by `splice_batch_equals_naive`.
940            store.splice_sorted_batch(&spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
941        }
942        // An active match a window removed transfers to a re-created match at
943        // its exact (post-patch) start, if any — the position half moves with
944        // it (lockstep).
945        if let Some(start) = removed_active_start {
946            let found = store
947                .decorations_in(start..start)
948                .find(|r| is_find(r) && r.range.start == start);
949            self.active = found.as_ref().map(|r| r.id);
950            self.active_start = found.map(|r| r.range.start);
951        }
952        // Cap bookkeeping — only when it can matter. The O(1) `find_count`
953        // gates it (not `store.len()`, which counts diagnostics/snippets too, so
954        // gating on it would scale with unrelated decorations). The BODY is
955        // O(log) too: the live count is `find_count()` (O(1) root read) and the
956        // two trim boundaries are ordinal `nth_find` lookups (O(log M)) — so a
957        // capped commit costs the same regardless of decoration count (pinned by
958        // `capped_commit_is_decoration_count_independent`).
959        let fc = store.find_count();
960        if fc > FIND_MATCH_CAP {
961            // Trim from the TAIL: the set stays a prefix of the document. The two
962            // boundaries are the (cap-1)-th and cap-th finds in `(start,id)` order.
963            if let (Some((_, kept)), Some((_, first_trimmed))) =
964                (store.nth_find(FIND_MATCH_CAP - 1), store.nth_find(FIND_MATCH_CAP))
965            {
966                let (kept_end, trim_from) = (kept.end, first_trimmed.start);
967                store.take_matching_in(trim_from..u32::MAX, |r| {
968                    is_find(r) && r.range.start >= trim_from
969                });
970                self.capped = true;
971                self.coverage_end = kept_end;
972                self.capped_stale = false; // full to the brim — nothing to refill
973            }
974        } else if self.capped {
975            // Below the cap with frozen coverage: the refill can grow it.
976            self.capped_stale = fc < FIND_MATCH_CAP;
977        }
978        // An active match destroyed without passing through a window (e.g.
979        // collapsed and dropped by `apply_patch`, or tail-trimmed) clears —
980        // probed at the tracked start (O(log)). `active_start` tracks the
981        // decoration's start exactly (Bias::Right), so a present match overlaps
982        // the zero-width `s..s` query.
983        if let (Some(id), Some(s)) = (self.active, self.active_start) {
984            let present = store.decorations_in(s..s).any(|r| is_find(&r) && r.id == id);
985            if !present {
986                self.active = None;
987                self.active_start = None;
988            }
989        }
990    }
991
992    /// Select the next match from the caret and return its live range. The set
993    /// is always current, so this is a pure store walk. `head`/`selection` are
994    /// the newest selection's head and extent.
995    pub fn find_next(
996        &mut self,
997        head: u32,
998        selection: Range<u32>,
999        store: &DecorationStore,
1000    ) -> Option<Range<u32>> {
1001        self.navigate(head, selection, store, true)
1002    }
1003
1004    /// Select the previous match from the caret — the
1005    /// [`find_next`](Self::find_next) mirror.
1006    pub fn find_prev(
1007        &mut self,
1008        head: u32,
1009        selection: Range<u32>,
1010        store: &DecorationStore,
1011    ) -> Option<Range<u32>> {
1012        self.navigate(head, selection, store, false)
1013    }
1014
1015    /// Wholesale-replace the `FindMatch` decorations from a fresh scan — the
1016    /// query-change path and the capped refill (the two legitimately
1017    /// whole-document find ops). The active match survives iff a new match
1018    /// starts exactly at the old active's tracked start.
1019    fn rescan(&mut self, buffer: &Buffer, store: &mut DecorationStore, now_ms: u64) {
1020        let old_active_start =
1021            self.active.and_then(|id| store.decoration_range(id)).map(|r| r.start);
1022        store.take_matching_in(0..u32::MAX, is_find);
1023        self.active = None;
1024        self.active_start = None;
1025        if let Some(q) = &self.query {
1026            // Scoped find scans only its range; unscoped, the whole document.
1027            let within = self.scope.clone().unwrap_or(0..buffer.len());
1028            let (spans, capped) = scan_buffer(buffer, q, crate::buffer::SCAN_WINDOW, within);
1029            self.capped = capped;
1030            self.coverage_end =
1031                if capped { spans.last().map_or(0, |s| s.end) } else { buffer.len() };
1032            let ids = store.add_sorted_batch(
1033                spans.iter().cloned(),
1034                DecorationKind::FindMatch,
1035                Stickiness::NeverGrows,
1036            );
1037            if let Some(start) = old_active_start {
1038                if let Some(i) = spans.iter().position(|s| s.start == start) {
1039                    self.active = Some(ids[i]); // the active match survives
1040                    self.active_start = Some(spans[i].start); // == start (lockstep)
1041                }
1042            }
1043        } else {
1044            self.capped = false;
1045            self.coverage_end = buffer.len();
1046        }
1047        self.capped_stale = false;
1048        self.last_scan_ms = now_ms;
1049    }
1050
1051    /// Pick the next/previous match relative to the caret and set the active
1052    /// id. The live set is the store's `FindMatch` decorations in `(start, id)`
1053    /// = document order; collapsed (empty) ones are skipped (normally vacuous,
1054    /// since the mover drops them). Sitting exactly on a match steps by position
1055    /// (repeated-press cycling); otherwise it scans from `head` and wraps.
1056    fn navigate(
1057        &mut self,
1058        head: u32,
1059        selection: Range<u32>,
1060        store: &DecorationStore,
1061        forward: bool,
1062    ) -> Option<Range<u32>> {
1063        // The live set is the store's `FindMatch` decorations in `(start, id)`
1064        // order — but we never materialize it. Count, rank, and the r-th match
1065        // are three O(log) store queries that reproduce the full-list branch
1066        // table exactly (pinned by `navigate_equals…`).
1067        let count = store.find_count();
1068        if count == 0 {
1069            self.active = None;
1070            self.active_start = None;
1071            return None;
1072        }
1073        // "On a match" ⇔ the find starting at `selection.start` IS `selection`.
1074        // `find_rank_before(selection.start)` is that find's rank in one descent;
1075        // only a find whose start equals `selection.start` can equal `selection`
1076        // (finds are disjoint), so this reproduces `position(|r| *r == selection)`.
1077        let on = {
1078            let r = store.find_rank_before(selection.start);
1079            store.nth_find(r).filter(|(_, rng)| *rng == selection).map(|_| r)
1080        };
1081        let pick = match (on, forward) {
1082            (Some(r), true) => (r + 1) % count,
1083            (Some(r), false) => (r + count - 1) % count,
1084            // First find with start ≥ head, wrapping past the last to 0.
1085            (None, true) => store.find_rank_before(head) % count,
1086            // Last find with start < head, wrapping before the first to count−1.
1087            (None, false) => (store.find_rank_before(head) + count - 1) % count,
1088        };
1089        let (id, range) = store.nth_find(pick)?;
1090        self.active = Some(id);
1091        self.active_start = Some(range.start);
1092        Some(range)
1093    }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099
1100    fn q(text: &str, case_sensitive: bool) -> FindQuery {
1101        FindQuery { text: text.into(), case_sensitive, ..Default::default() }
1102    }
1103
1104    /// A whole-word query — the `ab|` option.
1105    fn qw(text: &str, case_sensitive: bool) -> FindQuery {
1106        FindQuery { text: text.into(), case_sensitive, whole_word: true, ..Default::default() }
1107    }
1108
1109    /// A regex query — the `.*` option.
1110    fn qr(text: &str, case_sensitive: bool) -> FindQuery {
1111        FindQuery { text: text.into(), case_sensitive, regex: true, ..Default::default() }
1112    }
1113
1114    #[test]
1115    fn matches_are_leftmost_and_non_overlapping() {
1116        // "aaaa" / "aa" pairs into two, not three overlapping.
1117        assert_eq!(scan("aaaa", &q("aa", true)), (vec![0..2, 2..4], false));
1118        // "ababa" / "aba": second probe starts at 3, "ba" is too short → one hit.
1119        let (spans, capped) = scan("ababa", &q("aba", true));
1120        assert_eq!((spans.len(), spans.first().cloned(), capped), (1, Some(0..3), false));
1121    }
1122
1123    #[test]
1124    fn case_fold_is_ascii_and_toggled() {
1125        assert_eq!(scan("AbAb", &q("ab", false)), (vec![0..2, 2..4], false));
1126        assert_eq!(scan("AbAb", &q("ab", true)).0, Vec::<Range<u32>>::new());
1127        assert_eq!(scan("AbAb", &q("Ab", true)), (vec![0..2, 2..4], false));
1128    }
1129
1130    #[test]
1131    fn empty_query_matches_nothing() {
1132        assert_eq!(scan("anything", &q("", false)), (Vec::new(), false));
1133        assert_eq!(scan("anything", &q("", true)), (Vec::new(), false));
1134    }
1135
1136    #[test]
1137    fn a_query_longer_than_the_text_finds_nothing() {
1138        assert_eq!(scan("hi", &q("hello", true)), (Vec::new(), false));
1139    }
1140
1141    #[test]
1142    fn scan_equals_the_naive_oracle() {
1143        // The memchr fast paths must be bit-identical to the straightforward
1144        // naive scan, kept here verbatim as the oracle.
1145        fn naive(text: &str, query: &FindQuery) -> (Vec<Range<u32>>, bool) {
1146            let needle = query.text.as_bytes();
1147            if needle.is_empty() {
1148                return (Vec::new(), false);
1149            }
1150            let hay = text.as_bytes();
1151            let hit = |window: &[u8]| {
1152                if query.case_sensitive {
1153                    window == needle
1154                } else {
1155                    window.iter().zip(needle).all(|(a, b)| a.eq_ignore_ascii_case(b))
1156                }
1157            };
1158            let mut spans = Vec::new();
1159            let mut i = 0;
1160            while i + needle.len() <= hay.len() {
1161                if hit(&hay[i..i + needle.len()]) {
1162                    if spans.len() == FIND_MATCH_CAP {
1163                        return (spans, true);
1164                    }
1165                    spans.push(i as u32..(i + needle.len()) as u32);
1166                    i += needle.len();
1167                } else {
1168                    i += 1;
1169                }
1170            }
1171            (spans, false)
1172        }
1173
1174        let mut rng = 0x2545F4914F6CDD1Du64;
1175        let mut next = move || {
1176            rng ^= rng << 13;
1177            rng ^= rng >> 7;
1178            rng ^= rng << 17;
1179            rng
1180        };
1181        let alphabet = ['a', 'A', 'b', 'B', 'c', ' '];
1182        for round in 0..200 {
1183            let text: String = (0..(next() % 120)).map(|_| alphabet[(next() % 6) as usize]).collect();
1184            let needle: String = (0..1 + (next() % 4)).map(|_| alphabet[(next() % 6) as usize]).collect();
1185            for cs in [true, false] {
1186                let query = q(&needle, cs);
1187                assert_eq!(
1188                    scan(&text, &query),
1189                    naive(&text, &query),
1190                    "round {round}: {needle:?} (cs={cs}) in {text:?}"
1191                );
1192            }
1193        }
1194    }
1195
1196    #[test]
1197    fn scan_caps_and_reports() {
1198        // One past the cap trips it (both case paths)…
1199        let text = "ab".repeat(FIND_MATCH_CAP + 5);
1200        let (spans, capped) = scan(&text, &q("ab", true));
1201        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, true));
1202        let text = "aB".repeat(FIND_MATCH_CAP + 1);
1203        let (spans, capped) = scan(&text, &q("ab", false));
1204        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, true));
1205        // …exactly the cap does not (the cap trips only on an overflowing hit).
1206        let text = "ab".repeat(FIND_MATCH_CAP);
1207        let (spans, capped) = scan(&text, &q("ab", true));
1208        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, false));
1209    }
1210
1211    /// The windowed buffer scan must be byte-identical to `scan` over the
1212    /// materialized text — spans AND capped flag — for every window size,
1213    /// including windows tiny enough that matches straddle every seam, the
1214    /// needle exceeds the window, multibyte chars sit on window ends (the
1215    /// boundary-snap paths), and self-overlapping needles cross seams.
1216    #[test]
1217    fn windowed_scan_equals_the_whole_text_scan() {
1218        let corpora = [
1219            String::new(),
1220            "aaaa".into(),
1221            "abababab".into(),
1222            "aa aa aaa aaaa a".into(),
1223            "the fox\nreturns the fox to the FOX den\nfoxfoxfox\n".repeat(40),
1224            // Multibyte chars packed so window ends land mid-char for most
1225            // small window sizes; matches sit between and across them.
1226            "ä🦀ab🦀äab日本語ab".repeat(30),
1227            "🦀🦀🦀ab🦀🦀🦀".repeat(50),
1228        ];
1229        let needles = ["a", "ab", "aa", "aaa", "fox", "FOX", "🦀ä", "ab🦀", "abababababab", "語ab"];
1230        for text in &corpora {
1231            let b = Buffer::new(text).unwrap();
1232            let full_text = b.text();
1233            for needle in needles {
1234                for cs in [true, false] {
1235                    let query = q(needle, cs);
1236                    let expect = scan(&full_text, &query);
1237                    for window in [2, 3, 5, 7, 16, 64, 4096] {
1238                        assert_eq!(
1239                            scan_buffer(&b, &query, window, 0..b.len()),
1240                            expect,
1241                            "window {window}, needle {needle:?} (cs={cs}) in {:?}…",
1242                            &text[..text.len().min(24)]
1243                        );
1244                    }
1245                }
1246            }
1247        }
1248    }
1249
1250    /// Whole-word boundaries at a LINE's edges. The character outside a match at
1251    /// the start or end of a line is `\n` (or the document edge) — both
1252    /// non-word — so such a word IS whole. This is precisely the case the
1253    /// line-scoped model gets right for free and a byte-window scan cannot: a
1254    /// window's slice does not contain the character before its own first byte.
1255    #[test]
1256    fn whole_word_is_bounded_by_line_edges() {
1257        //          0123456 7 89.. 11 12....18 19
1258        let text = "foo bar\nfoo\nbarfoo foo";
1259        assert_eq!(
1260            scan(text, &qw("foo", false)).0,
1261            vec![0..3, 8..11, 19..22],
1262            "line-start, whole-line, and line-end words are whole; `barfoo` is not"
1263        );
1264        // Without the option, the substring inside `barfoo` matches too.
1265        assert_eq!(scan(text, &q("foo", false)).0, vec![0..3, 8..11, 15..18, 19..22]);
1266    }
1267
1268    /// A regex can never match across a line: the engine is fed one line at a
1269    /// time WITHOUT its newline, so `\n` is not in the haystack at all — this is
1270    /// structural, not a convention, and it is what keeps the per-keystroke
1271    /// repair windowed for a variable-length pattern.
1272    #[test]
1273    fn a_regex_can_never_match_across_a_line() {
1274        let text = "foo\nbar";
1275        for pattern in [r"foo\nbar", r"foo.bar", r"foo\s+bar", r"foo[^x]bar"] {
1276            assert!(
1277                scan(text, &qr(pattern, false)).0.is_empty(),
1278                "{pattern:?} must not span the newline"
1279            );
1280        }
1281        // …while each line matches on its own, with `^`/`$` meaning its edges.
1282        assert_eq!(scan(text, &qr("^foo$", false)).0, vec![0..3]);
1283        assert_eq!(scan(text, &qr("^bar$", false)).0, vec![4..7]);
1284        assert_eq!(scan(text, &qr("[a-z]+", false)).0, vec![0..3, 4..7]);
1285    }
1286
1287    /// An unfinished pattern is a NORMAL state — it is what every prefix of a
1288    /// regex being typed looks like — so it matches nothing rather than
1289    /// erroring, and resumes the moment it parses.
1290    #[test]
1291    fn an_unfinished_pattern_matches_nothing() {
1292        for pattern in ["(", "[a-", "a{2", "*"] {
1293            assert!(scan("aaa (bbb)", &qr(pattern, false)).0.is_empty(), "{pattern:?}");
1294        }
1295        assert_eq!(scan("aaa (bbb)", &qr(r"\(b+\)", false)).0, vec![4..9]);
1296    }
1297
1298    /// Zero-width hits are dropped: there is nothing to navigate to, highlight,
1299    /// or replace, and the store would discard them anyway — so the count stays
1300    /// honest about what the user can act on.
1301    #[test]
1302    fn zero_width_regex_hits_are_dropped() {
1303        assert!(scan("bbb\n\nbbb", &qr("a*", false)).0.is_empty());
1304        assert_eq!(scan("bab", &qr("a*", false)).0, vec![1..2], "only the non-empty hit");
1305    }
1306
1307    /// Whole-word compiles to `\b…\b` around the ESCAPED literal, so regex
1308    /// metacharacters in a plain whole-word query stay literal.
1309    #[test]
1310    fn whole_word_escapes_its_literal() {
1311        assert_eq!(scan("a.c abc", &qw("a.c", false)).0, vec![0..3], "`.` is literal here");
1312        // …and a whole-word REGEX groups the pattern before wrapping it, so
1313        // alternation binds inside the boundaries.
1314        let q = FindQuery {
1315            text: "foo|bar".into(),
1316            whole_word: true,
1317            regex: true,
1318            ..Default::default()
1319        };
1320        assert_eq!(scan("foo bar foobar", &q).0, vec![0..3, 4..7], "not `\\bfoo|bar\\b`");
1321    }
1322
1323    /// A **scoped** scan must equal the whole-document scan restricted to the
1324    /// scope — the oracle for find-in-selection. Scanning a sub-range is not the
1325    /// same as filtering the full scan (the greedy non-overlap cursor restarts
1326    /// at the scope's start), so this pins the one case where they must agree:
1327    /// a scope whose start sits on a match boundary. Scope edges are swept
1328    /// across every offset, including mid-char ones, which must clip inward
1329    /// rather than panic.
1330    #[test]
1331    fn scoped_scan_equals_the_whole_text_scan_within_the_scope() {
1332        let corpora = [
1333            "fox fox fox fox fox".to_string(),
1334            "the fox\nreturns the fox to the FOX den\nfoxfoxfox\n".repeat(8),
1335            "ä🦀ab🦀äab日本語ab".repeat(6),
1336        ];
1337        for text in &corpora {
1338            let b = Buffer::new(text).unwrap();
1339            let full = b.text();
1340            for needle in ["a", "ab", "fox", "🦀ä"] {
1341                for cs in [true, false] {
1342                    let query = q(needle, cs);
1343                    let all = scan(&full, &query).0;
1344                    for lo in 0..=b.len() {
1345                        for hi in lo..=b.len() {
1346                            // Snap the bound the way the scan does, then take
1347                            // every whole-document match that fits inside it.
1348                            let (slo, shi) =
1349                                (b.clip_offset(lo, Bias::Right), b.clip_offset(hi, Bias::Left));
1350                            if slo > shi {
1351                                continue;
1352                            }
1353                            let expect: Vec<_> = all
1354                                .iter()
1355                                .filter(|m| m.start >= slo && m.end <= shi)
1356                                .cloned()
1357                                .collect();
1358                            let got = scan_buffer(&b, &query, 4096, lo..hi).0;
1359                            // Only compare where the scope opens ON a match
1360                            // boundary; elsewhere a scope can legitimately expose
1361                            // a match the greedy full scan skipped.
1362                            if expect.first().is_none_or(|m| m.start == slo)
1363                                && all.iter().all(|m| m.end <= slo || m.start >= slo)
1364                            {
1365                                assert_eq!(got, expect, "scope {lo}..{hi}, needle {needle:?}");
1366                            }
1367                            // Unconditionally: never escape the scope.
1368                            assert!(
1369                                got.iter().all(|m| m.start >= slo && m.end <= shi),
1370                                "scope {lo}..{hi} leaked a match {got:?}"
1371                            );
1372                        }
1373                    }
1374                }
1375            }
1376        }
1377    }
1378
1379    /// The windowed scan reproduces the cap semantics exactly: capped ⇒ the
1380    /// identical 10k-prefix, and exactly-at-cap ⇒ not capped.
1381    #[test]
1382    fn windowed_scan_caps_like_the_whole_text_scan() {
1383        for extra in [0usize, 3] {
1384            let text = "ab".repeat(FIND_MATCH_CAP + extra);
1385            let b = Buffer::new(&text).unwrap();
1386            let query = q("ab", true);
1387            assert_eq!(
1388                scan_buffer(&b, &query, 4096, 0..b.len()),
1389                scan(&b.text(), &query),
1390                "extra {extra}"
1391            );
1392        }
1393    }
1394
1395    /// The O(log) [`FindState::navigate`]
1396    /// (`find_count`/`find_rank_before`/`nth_find`) must pick the byte-identical
1397    /// `(id, range)` — and set `active`/`active_start` in lockstep — as a
1398    /// straightforward whole-list walk, over random find sets and random
1399    /// `(head, selection, forward)`: on-match cycling both ways, off-match scan +
1400    /// wrap both ways, and the empty set. Non-find decorations are interleaved as
1401    /// noise that neither path may count.
1402    #[test]
1403    fn navigate_equals_the_full_list_walk() {
1404        // The full-list navigate, retained verbatim as the oracle.
1405        fn navigate_ref(
1406            store: &DecorationStore,
1407            head: u32,
1408            selection: Range<u32>,
1409            forward: bool,
1410        ) -> Option<(DecorationId, Range<u32>)> {
1411            let live: Vec<(DecorationId, Range<u32>)> = store
1412                .decorations_in(0..u32::MAX)
1413                .filter(|r| is_find(r) && r.range.start < r.range.end)
1414                .map(|r| (r.id, r.range.clone()))
1415                .collect();
1416            if live.is_empty() {
1417                return None;
1418            }
1419            let on = live.iter().position(|(_, r)| *r == selection);
1420            let pick = match (on, forward) {
1421                (Some(p), true) => (p + 1) % live.len(),
1422                (Some(p), false) => (p + live.len() - 1) % live.len(),
1423                (None, true) => live.iter().position(|(_, r)| r.start >= head).unwrap_or(0),
1424                (None, false) => {
1425                    live.iter().rposition(|(_, r)| r.start < head).unwrap_or(live.len() - 1)
1426                }
1427            };
1428            Some(live[pick].clone())
1429        }
1430
1431        let mut rng = 0x1234_5678_9ABC_DEF0u64;
1432        let mut next = move || {
1433            rng ^= rng << 13;
1434            rng ^= rng >> 7;
1435            rng ^= rng << 17;
1436            rng
1437        };
1438        for trial in 0..3000u32 {
1439            let mut store = DecorationStore::new();
1440            // Disjoint ascending non-empty finds.
1441            let n = next() % 12;
1442            let mut pos = 0u32;
1443            let mut find_spans: Vec<Range<u32>> = Vec::new();
1444            for _ in 0..n {
1445                pos += (next() % 5) as u32;
1446                let len = 1 + (next() % 4) as u32;
1447                find_spans.push(pos..pos + len);
1448                pos += len;
1449            }
1450            store.add_sorted_batch(
1451                find_spans.iter().cloned(),
1452                DecorationKind::FindMatch,
1453                Stickiness::NeverGrows,
1454            );
1455            // Non-find noise (must be ignored by both paths).
1456            for _ in 0..(next() % 5) {
1457                let s = (next() % u64::from(pos + 1)) as u32;
1458                let l = (next() % 4) as u32;
1459                store.add_decoration(s..s + l, DecorationKind::AutoClosePair, Stickiness::AlwaysGrows);
1460            }
1461            // No persisted find may be empty (FindMatch is EmptyPolicy::Drop);
1462            // the O(1) `find_count` counts all finds, so this must hold for it to
1463            // equal a `start < end` filter.
1464            debug_assert!(
1465                store.decorations_in(0..u32::MAX).filter(is_find).all(|r| r.range.start < r.range.end),
1466                "trial {trial}: an empty FindMatch persisted"
1467            );
1468
1469            let head = (next() % u64::from(pos + 6)) as u32;
1470            // Half the time, sit exactly on a match to exercise on-match cycling.
1471            let selection = if !find_spans.is_empty() && next() % 2 == 0 {
1472                find_spans[(next() as usize) % find_spans.len()].clone()
1473            } else {
1474                let a = (next() % u64::from(pos + 6)) as u32;
1475                let b = a + (next() % 5) as u32;
1476                a..b
1477            };
1478            for forward in [true, false] {
1479                let want = navigate_ref(&store, head, selection.clone(), forward);
1480                let mut state = FindState::new();
1481                let got = state.navigate(head, selection.clone(), &store, forward);
1482                match (&want, &got) {
1483                    (Some((id, range)), Some(got_range)) => {
1484                        assert_eq!(got_range, range, "trial {trial} fwd={forward}: range");
1485                        assert_eq!(state.active, Some(*id), "trial {trial} fwd={forward}: active id");
1486                        assert_eq!(
1487                            state.active_start,
1488                            Some(range.start),
1489                            "trial {trial} fwd={forward}: active_start tracks the pick"
1490                        );
1491                    }
1492                    (None, None) => {
1493                        assert_eq!(state.active, None, "trial {trial} fwd={forward}");
1494                        assert_eq!(state.active_start, None, "trial {trial} fwd={forward}");
1495                    }
1496                    _ => panic!("trial {trial} fwd={forward}: {want:?} vs {got:?}"),
1497                }
1498            }
1499        }
1500    }
1501}