Skip to main content

scrive_core/
find.rs

1//! Literal find — the search core.
2//!
3//! Search is **literal substring + an ASCII case-fold toggle** — no regex, no
4//! whole-word — a literal scan covers the common editing workflows without
5//! either. The search *bar* is app-side chrome; this module owns only
6//! the match model and the scan.
7//!
8//! The one design choice: the match set **lives only in the decoration store**
9//! — the sorted `FindMatch` decorations ARE the match set, in document order
10//! (one fact, one owner). [`FindState`] holds the query and the scan
11//! bookkeeping, never a shadow list of handles. The set is kept current
12//! **eagerly**: every committed transaction (forward edit, undo, redo — they
13//! share the same view-rebase path) runs [`FindState::on_commit`], a windowed
14//! repair around each edit, so consumers never see a stale match. The one
15//! remaining debounced path is the capped-set refill
16//! ([`FindState::maybe_rescan`]).
17//!
18//! Documented, test-pinned relaxation: for needles that cannot overlap
19//! themselves the repaired set is byte-identical to a fresh [`scan`]; a
20//! self-overlapping needle (`"aa"`) repairs to a *maximal valid*
21//! non-overlapping set whose phase near the edit may differ from the greedy
22//! full scan until the next query change.
23
24use core::ops::Range;
25
26use crate::buffer::Buffer;
27use crate::coords::Bias;
28use crate::decorations::{
29    DecorationId, DecorationKind, DecorationStore, Stickiness, TrackedRange,
30};
31use crate::patch::Patch;
32
33/// An active find query: a literal substring plus an ASCII case-fold toggle.
34/// `PartialEq` so the `Document` surface can no-op an unchanged query without
35/// re-scanning.
36#[derive(Clone, PartialEq, Eq, Debug)]
37pub struct FindQuery {
38    /// The literal text to find. Empty means "no matches" (never match-all).
39    pub text: String,
40    /// When `false`, `a`/`A` fold together. Case folding is ASCII-only.
41    pub case_sensitive: bool,
42}
43
44/// Debounce window (ms) for the capped-set refill — a user-facing default,
45/// exported as a `pub fn`. This is the ONLY debounced find path: ordinary edits
46/// repair the match set eagerly at the commit.
47#[must_use]
48pub fn default_find_debounce() -> u64 {
49    100
50}
51
52/// The scan stops after this many matches and records `capped` — cheap
53/// insurance against a pathological query. A capped set is a *prefix of the
54/// document*: everything up to [`FindState`]'s coverage frontier is exact,
55/// nothing beyond it is represented.
56pub const FIND_MATCH_CAP: usize = 10_000;
57
58/// Every non-overlapping match of `query` in `text`, leftmost-first, as byte
59/// spans; the `bool` is whether the scan hit [`FIND_MATCH_CAP`].
60///
61/// Literal byte-wise substring — the next probe starts at the previous match's
62/// end (non-overlapping). Case-insensitive mode folds **ASCII only** (the
63/// documented limitation). An empty query yields no matches, never match-all.
64/// Byte-wise is exact for the ASCII DSL; a multi-byte needle is out of scope.
65///
66/// This is the pure oracle every other find path defers to: the query-change
67/// full scan, the capped refill, and the per-edit window repairs all run this
68/// same function, so the rules cannot fork. Case-sensitive search rides
69/// `memchr::memmem`; the fold path probes the needle's first byte's two case
70/// forms via `memchr2` and verifies each candidate window — both bit-identical
71/// to the naive reference scan (pinned by `scan_equals_the_naive_oracle`).
72#[must_use]
73pub fn scan(text: &str, query: &FindQuery) -> (Vec<Range<u32>>, bool) {
74    let needle = query.text.as_bytes();
75    if needle.is_empty() {
76        return (Vec::new(), false); // empty needle = zero matches
77    }
78    let hay = text.as_bytes();
79    let mut spans = Vec::new();
80    if query.case_sensitive {
81        // memmem yields non-overlapping occurrences leftmost-first — the next
82        // probe starts at the previous match's end.
83        for i in memchr::memmem::find_iter(hay, needle) {
84            if spans.len() == FIND_MATCH_CAP {
85                return (spans, true); // cap reached
86            }
87            spans.push(i as u32..(i + needle.len()) as u32);
88        }
89    } else {
90        let (lo, up) = (needle[0].to_ascii_lowercase(), needle[0].to_ascii_uppercase());
91        let mut i = 0usize;
92        while i + needle.len() <= hay.len() {
93            // Jump to the next candidate first byte (either case form)…
94            let Some(j) = memchr::memchr2(lo, up, &hay[i..]) else {
95                break;
96            };
97            let c = i + j;
98            if c + needle.len() > hay.len() {
99                break;
100            }
101            // …and verify the window byte-wise (ASCII-only fold).
102            if hay[c..c + needle.len()].eq_ignore_ascii_case(needle) {
103                if spans.len() == FIND_MATCH_CAP {
104                    return (spans, true); // cap reached
105                }
106                spans.push(c as u32..(c + needle.len()) as u32);
107                i = c + needle.len(); // non-overlapping: resume past this match
108            } else {
109                i = c + 1;
110            }
111        }
112    }
113    (spans, false)
114}
115
116/// [`scan`] driven through the buffer's backing-agnostic ranged reads:
117/// fixed-size windows with a needle-length−1 overlap, so a match straddling a
118/// window boundary is found by the next window and the result is
119/// **byte-identical to `scan(&buffer.text(), query)`** (pinned by
120/// `windowed_scan_equals_the_whole_text_scan`). Never materializes the
121/// document — peak transient is one window — and a capped dense query stops
122/// after O(bytes-until-cap), which `buffer.text()` would defeat by copying
123/// the whole rope up front.
124fn scan_buffer(buffer: &Buffer, query: &FindQuery, window: u32) -> (Vec<Range<u32>>, bool) {
125    let k = query.text.len() as u64;
126    if k == 0 {
127        return (Vec::new(), false); // empty needle = zero matches
128    }
129    let len = buffer.len();
130    // ≥ 2k, so every iteration provably advances even for giant needles.
131    let window = u64::from(window).max(k * 2);
132    let mut spans: Vec<Range<u32>> = Vec::new();
133    let mut pos: u32 = 0; // the non-overlapping scan cursor (next probe start)
134    while u64::from(pos) + k <= u64::from(len) {
135        // The window end snaps RIGHT to a char boundary: `slice` stays on the
136        // str-slicing contract, the window is never empty, and growing a
137        // window never loses a candidate.
138        let win_end = buffer
139            .clip_offset((u64::from(pos) + window).min(u64::from(len)) as u32, Bias::Right);
140        let (local, local_capped) = scan(&buffer.slice(pos..win_end), query);
141        let found = !local.is_empty();
142        for r in local {
143            if spans.len() == FIND_MATCH_CAP {
144                return (spans, true); // cap reached
145            }
146            spans.push(pos + r.start..pos + r.end);
147        }
148        // A capped in-window scan left the window's tail unscanned — keep
149        // going from the last match even at the document end (the overflow
150        // match it found still has to trip the cap above).
151        if win_end == len && !local_capped {
152            break;
153        }
154        // Resume through the one owner of the seam logic ([`Buffer::scan_resume`]):
155        // past the last match when the window found one (the non-overlap cursor —
156        // the window's tail may be re-scanned but is never skipped), else at the
157        // char boundary before `win_end − (k−1)`.
158        let last_end = found.then(|| spans.last().expect("found ⇒ spans non-empty").end);
159        pos = buffer.scan_resume(pos, win_end, k as u32, last_end);
160    }
161    (spans, false)
162}
163
164/// Whether a tracked range is a find match — the one predicate the store
165/// queries and batch removals share.
166fn is_find(r: &TrackedRange) -> bool {
167    matches!(r.kind, DecorationKind::FindMatch)
168}
169
170/// The search view-state: the active query, the active match's decoration id,
171/// and the coverage/cap bookkeeping.
172///
173/// The one design rule: **the store IS the match set** — the sorted `FindMatch`
174/// decorations, in `(start, id)` = document order. This state deliberately
175/// holds no handle list; count, order, and N-of-M all derive from store queries
176/// (one fact, one owner). The set is repaired eagerly per commit via
177/// [`FindState::on_commit`], so it is always current — undo/redo inherit the
178/// repair through the shared view-rebase mover.
179#[derive(Debug, Default)]
180pub struct FindState {
181    query: Option<FindQuery>,
182    /// The decoration id of the highlighted/navigated match, if any.
183    active: Option<DecorationId>,
184    /// The active match's tracked START offset — the position half of the active
185    /// handle, kept in lockstep with [`active`](Self::active) (both `Some` or
186    /// both `None`). It rides each commit's patch in
187    /// [`on_commit`](Self::on_commit) with **`Bias::Right`** — the start bias of
188    /// a `FindMatch`'s `NeverGrows` stickiness — so it stays equal to the active
189    /// decoration's current start (pinned by `active_start_tracks…`). It earns
190    /// its place as the O(log) index that lets the per-commit presence check and
191    /// the N-of-M ordinal avoid an O(M) whole-store walk, not as a display
192    /// convenience.
193    active_start: Option<u32>,
194    /// Whether the match set is a capped *prefix* of the document.
195    capped: bool,
196    /// Byte offset the scan has covered: `buffer.len()` when `!capped`, else
197    /// the last kept match's end. Rides each commit's patch (`Bias::Left`);
198    /// while capped, repairs clamp their windows to it and edits beyond it are
199    /// the refill's job.
200    coverage_end: u32,
201    /// While capped: the live count has dropped below the cap, so coverage
202    /// could grow — the ONLY condition under which [`FindState::maybe_rescan`]
203    /// re-scans. (At exactly the cap the set provably equals the ideal capped
204    /// prefix: repairs keep the covered prefix exact, and its first
205    /// [`FIND_MATCH_CAP`] matches are the document's first ones.)
206    capped_stale: bool,
207    /// `now_ms` of the last full scan — the capped-refill debounce anchor.
208    last_scan_ms: u64,
209}
210
211impl FindState {
212    /// An empty state — no query, no matches.
213    #[must_use]
214    pub fn new() -> Self {
215        Self::default()
216    }
217
218    /// The active query, or `None` if find is idle.
219    #[must_use]
220    pub fn query(&self) -> Option<&FindQuery> {
221        self.query.as_ref()
222    }
223
224    /// How many live matches the current query has — the store IS the match set,
225    /// so this is the O(1) root-summary [`find_count`] read.
226    ///
227    /// [`find_count`]: DecorationStore::find_count
228    #[must_use]
229    pub fn match_count(&self, store: &DecorationStore) -> usize {
230        store.find_count()
231    }
232
233    /// The decoration handle of the active match, if any — the render layer
234    /// compares each `FindMatch` decoration to this to pick the distinct style.
235    #[must_use]
236    pub fn active_id(&self) -> Option<DecorationId> {
237        self.active
238    }
239
240    /// The active match's tracked start offset — the position half of the active
241    /// handle, in lockstep with [`active_id`](Self::active_id). `Document` reads
242    /// it for the O(log) N-of-M ordinal (`active_find_match`).
243    #[must_use]
244    pub(crate) fn active_start(&self) -> Option<u32> {
245        self.active_start
246    }
247
248    /// Whether the match set is a capped prefix of the document
249    /// ([`FIND_MATCH_CAP`]).
250    #[must_use]
251    pub fn capped(&self) -> bool {
252        self.capped
253    }
254
255    /// Set (or clear with `None`) the query and scan synchronously; never
256    /// scrolls, and clears the active match. A query equal to the current one is
257    /// a no-op (no needless re-scan).
258    ///
259    /// The full scan runs `scan_buffer` — whole-document in coverage but
260    /// windowed in memory, and a capped dense query stops at
261    /// O(bytes-until-cap) instead of copying the rope's tail.
262    pub fn set_query(
263        &mut self,
264        query: Option<FindQuery>,
265        buffer: &Buffer,
266        store: &mut DecorationStore,
267        now_ms: u64,
268    ) {
269        if query == self.query {
270            return;
271        }
272        self.query = query;
273        self.active = None; // a query change drops the active match
274        self.active_start = None; // …and its tracked position (lockstep)
275        self.rescan(buffer, store, now_ms);
276    }
277
278    /// Refill a **capped** match set: re-scan iff a query is active, the set is
279    /// capped with room below the cap, and the debounce window has elapsed.
280    /// `now_ms` is an injected monotonic clock — the widget passes real time,
281    /// the headless suite a fake. Returns whether it scanned.
282    ///
283    /// Because the eager per-commit repair keeps the set current, the only job
284    /// here is growing a capped set's coverage after matches inside it died.
285    pub fn maybe_rescan(&mut self, buffer: &Buffer, store: &mut DecorationStore, now_ms: u64) -> bool {
286        if self.query.is_none()
287            || !(self.capped && self.capped_stale)
288            || now_ms.saturating_sub(self.last_scan_ms) < default_find_debounce()
289        {
290            return false;
291        }
292        self.rescan(buffer, store, now_ms);
293        true
294    }
295
296    /// Repair the match set around a committed patch — the mover hook called
297    /// from the view-rebase path AFTER `DecorationStore::apply_patch` (it needs
298    /// post-patch positions). No-op when find is idle.
299    ///
300    /// Per edit (post-edit coordinates), with `k` = needle byte length: every
301    /// affected placement must overlap the influence window
302    /// `new.start−(k−1) .. new.end+(k−1)` (a created/destroyed match contains a
303    /// changed byte or the join point). Matches touching the window are removed
304    /// wholesale ([`DecorationStore::take_matching_in`]); the re-scan zone is
305    /// then the window widened (a) to the removed extents — a merely-touching
306    /// match must be re-findable, not lost — and (b) a further `k−1` bytes each
307    /// way, because a placement overlapping the removal zone (one whose old
308    /// greedy blocker just died — the self-overlap phase repair) can start up
309    /// to `k−1` bytes left of it and end as far right. Two clamps keep the
310    /// zone from double-owning text: the scan starts at the last surviving
311    /// match ending in the left margin (the anchor), and candidates
312    /// overlapping the first surviving match right of the zone are dropped
313    /// (the guard). The zone is greedily re-scanned with the same pure
314    /// [`scan`] and batch-reinserted. Matches outside every zone keep their
315    /// decoration ids (id stability is what lets the active match survive
316    /// unrelated edits).
317    ///
318    /// Active survival: untouched active stays (same id); an active removed by a
319    /// window transfers to a re-created match at its exact post-patch start;
320    /// otherwise it clears.
321    ///
322    /// Cap: `coverage_end` rides the patch first; windows clamp to it, so
323    /// repairs beyond a capped set's coverage are skipped (the refill's job).
324    /// If a repair pushes the live count past [`FIND_MATCH_CAP`], the tail is
325    /// trimmed so the set stays a prefix of the document.
326    pub fn on_commit(&mut self, patch: &Patch, buffer: &Buffer, store: &mut DecorationStore) {
327        let Some(query) = &self.query else { return };
328        let k = query.text.len() as u32;
329        if k == 0 || patch.is_empty() {
330            return; // an empty needle has no matches to repair
331        }
332        // Coverage rides the patch ONCE, before the per-edit loop; an uncapped
333        // set always covers the whole (post-edit) document.
334        self.coverage_end = if self.capped {
335            patch.map_offset(self.coverage_end, Bias::Left)
336        } else {
337            buffer.len()
338        };
339        // The active handle's position rides the SAME committed patch as the
340        // decoration it tracks — `Bias::Right`, a FindMatch's `NeverGrows` start
341        // bias — so it stays equal to that decoration's post-patch start.
342        self.active_start = self.active_start.map(|s| patch.map_offset(s, Bias::Right));
343        let mut removed_active_start: Option<u32> = None;
344        for e in patch.edits() {
345            // The influence window, snapped OUTWARD to char boundaries and
346            // clamped to the covered prefix.
347            let w_start = buffer.clip_offset(e.new.start.saturating_sub(k - 1), Bias::Left);
348            let w_end_raw = e.new.end.saturating_add(k - 1).min(self.coverage_end);
349            let w_end = buffer.clip_offset(w_end_raw, Bias::Right);
350            if w_start > w_end {
351                continue; // entirely beyond a capped coverage: refill's job
352            }
353            let removed = store.take_matching_in(w_start..w_end, is_find);
354            if let Some(active) = self.active {
355                if let Some(r) = removed.iter().find(|r| r.id == active) {
356                    removed_active_start = Some(r.range.start);
357                    self.active = None;
358                    self.active_start = None; // lockstep; the transfer below re-sets both
359                }
360            }
361            // Widen to the removed extents: a match that merely touched the
362            // window (start left of it / end right of it) must be re-findable,
363            // or removal would lose it.
364            let ext_lo =
365                removed.iter().map(|r| r.range.start).min().map_or(w_start, |s| s.min(w_start));
366            let ext_hi =
367                removed.iter().map(|r| r.range.end).max().map_or(w_end, |e| e.max(w_end));
368            // Widen a further k−1 each way: a placement overlapping the
369            // removal zone — one whose old greedy blocker died (self-overlap
370            // phase) — can start up to k−1 bytes left of `ext_lo` and end as
371            // far right of `ext_hi`. Removed-match boundaries are char
372            // boundaries (byte-wise hits of a valid-UTF-8 needle); the ±(k−1)
373            // arithmetic can unsnap, so clip again.
374            let scan_lo = buffer.clip_offset(ext_lo.saturating_sub(k - 1), Bias::Left);
375            let scan_hi = buffer
376                .clip_offset(ext_hi.saturating_add(k - 1).min(self.coverage_end), Bias::Right);
377            // Left anchor: the widened margin may hold a surviving match —
378            // start scanning at its end so its text is never double-owned.
379            // (Survivors intersecting `scan_lo..ext_lo` provably end at or
380            // before `ext_lo`: anything reaching further would have touched
381            // the window or a removed extent and been removed itself.)
382            let anchor = store
383                .decorations_in(scan_lo..ext_lo)
384                .filter(is_find)
385                .map(|r| r.range.end)
386                .max();
387            let scan_lo = anchor.map_or(scan_lo, |a| a.max(scan_lo));
388            // Right guard: candidates may not overlap the first surviving
389            // match right of the zone (survivors there provably start at or
390            // after `ext_hi`, same argument as the anchor).
391            let guard = store
392                .decorations_in(ext_hi..scan_hi)
393                .filter(|r| is_find(r) && r.range.start >= ext_hi)
394                .map(|r| r.range.start)
395                .min();
396            // Greedy re-scan through the one pure `scan`, mapped back to
397            // absolute offsets.
398            let slice = buffer.slice(scan_lo..scan_hi);
399            let (rel, _) = scan(&slice, query);
400            let mut spans: Vec<Range<u32>> =
401                rel.into_iter().map(|r| scan_lo + r.start..scan_lo + r.end).collect();
402            if let Some(guard) = guard {
403                spans.retain(|s| s.end <= guard);
404            }
405            // Windowed insert: the batch merges into its ≤band without the
406            // O(M log M) whole-store `to_vec()`+re-sort of `add_sorted_batch`.
407            // Byte-identical to that here (the window is already cleared of
408            // finds), pinned by `splice_batch_equals_naive`.
409            store.splice_sorted_batch(&spans, DecorationKind::FindMatch, Stickiness::NeverGrows);
410        }
411        // An active match a window removed transfers to a re-created match at
412        // its exact (post-patch) start, if any — the position half moves with
413        // it (lockstep).
414        if let Some(start) = removed_active_start {
415            let found = store
416                .decorations_in(start..start)
417                .find(|r| is_find(r) && r.range.start == start);
418            self.active = found.as_ref().map(|r| r.id);
419            self.active_start = found.map(|r| r.range.start);
420        }
421        // Cap bookkeeping — only when it can matter. The O(1) `find_count`
422        // gates it (not `store.len()`, which counts diagnostics/snippets too, so
423        // gating on it would scale with unrelated decorations). The BODY is
424        // O(log) too: the live count is `find_count()` (O(1) root read) and the
425        // two trim boundaries are ordinal `nth_find` lookups (O(log M)) — so a
426        // capped commit costs the same regardless of decoration count (pinned by
427        // `capped_commit_is_decoration_count_independent`).
428        let fc = store.find_count();
429        if fc > FIND_MATCH_CAP {
430            // Trim from the TAIL: the set stays a prefix of the document. The two
431            // boundaries are the (cap-1)-th and cap-th finds in `(start,id)` order.
432            if let (Some((_, kept)), Some((_, first_trimmed))) =
433                (store.nth_find(FIND_MATCH_CAP - 1), store.nth_find(FIND_MATCH_CAP))
434            {
435                let (kept_end, trim_from) = (kept.end, first_trimmed.start);
436                store.take_matching_in(trim_from..u32::MAX, |r| {
437                    is_find(r) && r.range.start >= trim_from
438                });
439                self.capped = true;
440                self.coverage_end = kept_end;
441                self.capped_stale = false; // full to the brim — nothing to refill
442            }
443        } else if self.capped {
444            // Below the cap with frozen coverage: the refill can grow it.
445            self.capped_stale = fc < FIND_MATCH_CAP;
446        }
447        // An active match destroyed without passing through a window (e.g.
448        // collapsed and dropped by `apply_patch`, or tail-trimmed) clears —
449        // probed at the tracked start (O(log)). `active_start` tracks the
450        // decoration's start exactly (Bias::Right), so a present match overlaps
451        // the zero-width `s..s` query.
452        if let (Some(id), Some(s)) = (self.active, self.active_start) {
453            let present = store.decorations_in(s..s).any(|r| is_find(&r) && r.id == id);
454            if !present {
455                self.active = None;
456                self.active_start = None;
457            }
458        }
459    }
460
461    /// Select the next match from the caret and return its live range. The set
462    /// is always current, so this is a pure store walk. `head`/`selection` are
463    /// the newest selection's head and extent.
464    pub fn find_next(
465        &mut self,
466        head: u32,
467        selection: Range<u32>,
468        store: &DecorationStore,
469    ) -> Option<Range<u32>> {
470        self.navigate(head, selection, store, true)
471    }
472
473    /// Select the previous match from the caret — the
474    /// [`find_next`](Self::find_next) mirror.
475    pub fn find_prev(
476        &mut self,
477        head: u32,
478        selection: Range<u32>,
479        store: &DecorationStore,
480    ) -> Option<Range<u32>> {
481        self.navigate(head, selection, store, false)
482    }
483
484    /// Wholesale-replace the `FindMatch` decorations from a fresh scan — the
485    /// query-change path and the capped refill (the two legitimately
486    /// whole-document find ops). The active match survives iff a new match
487    /// starts exactly at the old active's tracked start.
488    fn rescan(&mut self, buffer: &Buffer, store: &mut DecorationStore, now_ms: u64) {
489        let old_active_start =
490            self.active.and_then(|id| store.decoration_range(id)).map(|r| r.start);
491        store.take_matching_in(0..u32::MAX, is_find);
492        self.active = None;
493        self.active_start = None;
494        if let Some(q) = &self.query {
495            let (spans, capped) = scan_buffer(buffer, q, crate::buffer::SCAN_WINDOW);
496            self.capped = capped;
497            self.coverage_end =
498                if capped { spans.last().map_or(0, |s| s.end) } else { buffer.len() };
499            let ids = store.add_sorted_batch(
500                spans.iter().cloned(),
501                DecorationKind::FindMatch,
502                Stickiness::NeverGrows,
503            );
504            if let Some(start) = old_active_start {
505                if let Some(i) = spans.iter().position(|s| s.start == start) {
506                    self.active = Some(ids[i]); // the active match survives
507                    self.active_start = Some(spans[i].start); // == start (lockstep)
508                }
509            }
510        } else {
511            self.capped = false;
512            self.coverage_end = buffer.len();
513        }
514        self.capped_stale = false;
515        self.last_scan_ms = now_ms;
516    }
517
518    /// Pick the next/previous match relative to the caret and set the active
519    /// id. The live set is the store's `FindMatch` decorations in `(start, id)`
520    /// = document order; collapsed (empty) ones are skipped (normally vacuous,
521    /// since the mover drops them). Sitting exactly on a match steps by position
522    /// (repeated-press cycling); otherwise it scans from `head` and wraps.
523    fn navigate(
524        &mut self,
525        head: u32,
526        selection: Range<u32>,
527        store: &DecorationStore,
528        forward: bool,
529    ) -> Option<Range<u32>> {
530        // The live set is the store's `FindMatch` decorations in `(start, id)`
531        // order — but we never materialize it. Count, rank, and the r-th match
532        // are three O(log) store queries that reproduce the full-list branch
533        // table exactly (pinned by `navigate_equals…`).
534        let count = store.find_count();
535        if count == 0 {
536            self.active = None;
537            self.active_start = None;
538            return None;
539        }
540        // "On a match" ⇔ the find starting at `selection.start` IS `selection`.
541        // `find_rank_before(selection.start)` is that find's rank in one descent;
542        // only a find whose start equals `selection.start` can equal `selection`
543        // (finds are disjoint), so this reproduces `position(|r| *r == selection)`.
544        let on = {
545            let r = store.find_rank_before(selection.start);
546            store.nth_find(r).filter(|(_, rng)| *rng == selection).map(|_| r)
547        };
548        let pick = match (on, forward) {
549            (Some(r), true) => (r + 1) % count,
550            (Some(r), false) => (r + count - 1) % count,
551            // First find with start ≥ head, wrapping past the last to 0.
552            (None, true) => store.find_rank_before(head) % count,
553            // Last find with start < head, wrapping before the first to count−1.
554            (None, false) => (store.find_rank_before(head) + count - 1) % count,
555        };
556        let (id, range) = store.nth_find(pick)?;
557        self.active = Some(id);
558        self.active_start = Some(range.start);
559        Some(range)
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn q(text: &str, case_sensitive: bool) -> FindQuery {
568        FindQuery { text: text.into(), case_sensitive }
569    }
570
571    #[test]
572    fn matches_are_leftmost_and_non_overlapping() {
573        // "aaaa" / "aa" pairs into two, not three overlapping.
574        assert_eq!(scan("aaaa", &q("aa", true)), (vec![0..2, 2..4], false));
575        // "ababa" / "aba": second probe starts at 3, "ba" is too short → one hit.
576        let (spans, capped) = scan("ababa", &q("aba", true));
577        assert_eq!((spans.len(), spans.first().cloned(), capped), (1, Some(0..3), false));
578    }
579
580    #[test]
581    fn case_fold_is_ascii_and_toggled() {
582        assert_eq!(scan("AbAb", &q("ab", false)), (vec![0..2, 2..4], false));
583        assert_eq!(scan("AbAb", &q("ab", true)).0, Vec::<Range<u32>>::new());
584        assert_eq!(scan("AbAb", &q("Ab", true)), (vec![0..2, 2..4], false));
585    }
586
587    #[test]
588    fn empty_query_matches_nothing() {
589        assert_eq!(scan("anything", &q("", false)), (Vec::new(), false));
590        assert_eq!(scan("anything", &q("", true)), (Vec::new(), false));
591    }
592
593    #[test]
594    fn a_query_longer_than_the_text_finds_nothing() {
595        assert_eq!(scan("hi", &q("hello", true)), (Vec::new(), false));
596    }
597
598    #[test]
599    fn scan_equals_the_naive_oracle() {
600        // The memchr fast paths must be bit-identical to the straightforward
601        // naive scan, kept here verbatim as the oracle.
602        fn naive(text: &str, query: &FindQuery) -> (Vec<Range<u32>>, bool) {
603            let needle = query.text.as_bytes();
604            if needle.is_empty() {
605                return (Vec::new(), false);
606            }
607            let hay = text.as_bytes();
608            let hit = |window: &[u8]| {
609                if query.case_sensitive {
610                    window == needle
611                } else {
612                    window.iter().zip(needle).all(|(a, b)| a.eq_ignore_ascii_case(b))
613                }
614            };
615            let mut spans = Vec::new();
616            let mut i = 0;
617            while i + needle.len() <= hay.len() {
618                if hit(&hay[i..i + needle.len()]) {
619                    if spans.len() == FIND_MATCH_CAP {
620                        return (spans, true);
621                    }
622                    spans.push(i as u32..(i + needle.len()) as u32);
623                    i += needle.len();
624                } else {
625                    i += 1;
626                }
627            }
628            (spans, false)
629        }
630
631        let mut rng = 0x2545F4914F6CDD1Du64;
632        let mut next = move || {
633            rng ^= rng << 13;
634            rng ^= rng >> 7;
635            rng ^= rng << 17;
636            rng
637        };
638        let alphabet = ['a', 'A', 'b', 'B', 'c', ' '];
639        for round in 0..200 {
640            let text: String = (0..(next() % 120)).map(|_| alphabet[(next() % 6) as usize]).collect();
641            let needle: String = (0..1 + (next() % 4)).map(|_| alphabet[(next() % 6) as usize]).collect();
642            for cs in [true, false] {
643                let query = q(&needle, cs);
644                assert_eq!(
645                    scan(&text, &query),
646                    naive(&text, &query),
647                    "round {round}: {needle:?} (cs={cs}) in {text:?}"
648                );
649            }
650        }
651    }
652
653    #[test]
654    fn scan_caps_and_reports() {
655        // One past the cap trips it (both case paths)…
656        let text = "ab".repeat(FIND_MATCH_CAP + 5);
657        let (spans, capped) = scan(&text, &q("ab", true));
658        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, true));
659        let text = "aB".repeat(FIND_MATCH_CAP + 1);
660        let (spans, capped) = scan(&text, &q("ab", false));
661        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, true));
662        // …exactly the cap does not (the cap trips only on an overflowing hit).
663        let text = "ab".repeat(FIND_MATCH_CAP);
664        let (spans, capped) = scan(&text, &q("ab", true));
665        assert_eq!((spans.len(), capped), (FIND_MATCH_CAP, false));
666    }
667
668    /// The windowed buffer scan must be byte-identical to `scan` over the
669    /// materialized text — spans AND capped flag — for every window size,
670    /// including windows tiny enough that matches straddle every seam, the
671    /// needle exceeds the window, multibyte chars sit on window ends (the
672    /// boundary-snap paths), and self-overlapping needles cross seams.
673    #[test]
674    fn windowed_scan_equals_the_whole_text_scan() {
675        let corpora = [
676            String::new(),
677            "aaaa".into(),
678            "abababab".into(),
679            "aa aa aaa aaaa a".into(),
680            "the fox\nreturns the fox to the FOX den\nfoxfoxfox\n".repeat(40),
681            // Multibyte chars packed so window ends land mid-char for most
682            // small window sizes; matches sit between and across them.
683            "ä🦀ab🦀äab日本語ab".repeat(30),
684            "🦀🦀🦀ab🦀🦀🦀".repeat(50),
685        ];
686        let needles = ["a", "ab", "aa", "aaa", "fox", "FOX", "🦀ä", "ab🦀", "abababababab", "語ab"];
687        for text in &corpora {
688            let b = Buffer::new(text).unwrap();
689            let full_text = b.text();
690            for needle in needles {
691                for cs in [true, false] {
692                    let query = q(needle, cs);
693                    let expect = scan(&full_text, &query);
694                    for window in [2, 3, 5, 7, 16, 64, 4096] {
695                        assert_eq!(
696                            scan_buffer(&b, &query, window),
697                            expect,
698                            "window {window}, needle {needle:?} (cs={cs}) in {:?}…",
699                            &text[..text.len().min(24)]
700                        );
701                    }
702                }
703            }
704        }
705    }
706
707    /// The windowed scan reproduces the cap semantics exactly: capped ⇒ the
708    /// identical 10k-prefix, and exactly-at-cap ⇒ not capped.
709    #[test]
710    fn windowed_scan_caps_like_the_whole_text_scan() {
711        for extra in [0usize, 3] {
712            let text = "ab".repeat(FIND_MATCH_CAP + extra);
713            let b = Buffer::new(&text).unwrap();
714            let query = q("ab", true);
715            assert_eq!(scan_buffer(&b, &query, 4096), scan(&b.text(), &query), "extra {extra}");
716        }
717    }
718
719    /// The O(log) [`FindState::navigate`]
720    /// (`find_count`/`find_rank_before`/`nth_find`) must pick the byte-identical
721    /// `(id, range)` — and set `active`/`active_start` in lockstep — as a
722    /// straightforward whole-list walk, over random find sets and random
723    /// `(head, selection, forward)`: on-match cycling both ways, off-match scan +
724    /// wrap both ways, and the empty set. Non-find decorations are interleaved as
725    /// noise that neither path may count.
726    #[test]
727    fn navigate_equals_the_full_list_walk() {
728        // The full-list navigate, retained verbatim as the oracle.
729        fn navigate_ref(
730            store: &DecorationStore,
731            head: u32,
732            selection: Range<u32>,
733            forward: bool,
734        ) -> Option<(DecorationId, Range<u32>)> {
735            let live: Vec<(DecorationId, Range<u32>)> = store
736                .decorations_in(0..u32::MAX)
737                .filter(|r| is_find(r) && r.range.start < r.range.end)
738                .map(|r| (r.id, r.range.clone()))
739                .collect();
740            if live.is_empty() {
741                return None;
742            }
743            let on = live.iter().position(|(_, r)| *r == selection);
744            let pick = match (on, forward) {
745                (Some(p), true) => (p + 1) % live.len(),
746                (Some(p), false) => (p + live.len() - 1) % live.len(),
747                (None, true) => live.iter().position(|(_, r)| r.start >= head).unwrap_or(0),
748                (None, false) => {
749                    live.iter().rposition(|(_, r)| r.start < head).unwrap_or(live.len() - 1)
750                }
751            };
752            Some(live[pick].clone())
753        }
754
755        let mut rng = 0x1234_5678_9ABC_DEF0u64;
756        let mut next = move || {
757            rng ^= rng << 13;
758            rng ^= rng >> 7;
759            rng ^= rng << 17;
760            rng
761        };
762        for trial in 0..3000u32 {
763            let mut store = DecorationStore::new();
764            // Disjoint ascending non-empty finds.
765            let n = next() % 12;
766            let mut pos = 0u32;
767            let mut find_spans: Vec<Range<u32>> = Vec::new();
768            for _ in 0..n {
769                pos += (next() % 5) as u32;
770                let len = 1 + (next() % 4) as u32;
771                find_spans.push(pos..pos + len);
772                pos += len;
773            }
774            store.add_sorted_batch(
775                find_spans.iter().cloned(),
776                DecorationKind::FindMatch,
777                Stickiness::NeverGrows,
778            );
779            // Non-find noise (must be ignored by both paths).
780            for _ in 0..(next() % 5) {
781                let s = (next() % u64::from(pos + 1)) as u32;
782                let l = (next() % 4) as u32;
783                store.add_decoration(s..s + l, DecorationKind::AutoClosePair, Stickiness::AlwaysGrows);
784            }
785            // No persisted find may be empty (FindMatch is EmptyPolicy::Drop);
786            // the O(1) `find_count` counts all finds, so this must hold for it to
787            // equal a `start < end` filter.
788            debug_assert!(
789                store.decorations_in(0..u32::MAX).filter(is_find).all(|r| r.range.start < r.range.end),
790                "trial {trial}: an empty FindMatch persisted"
791            );
792
793            let head = (next() % u64::from(pos + 6)) as u32;
794            // Half the time, sit exactly on a match to exercise on-match cycling.
795            let selection = if !find_spans.is_empty() && next() % 2 == 0 {
796                find_spans[(next() as usize) % find_spans.len()].clone()
797            } else {
798                let a = (next() % u64::from(pos + 6)) as u32;
799                let b = a + (next() % 5) as u32;
800                a..b
801            };
802            for forward in [true, false] {
803                let want = navigate_ref(&store, head, selection.clone(), forward);
804                let mut state = FindState::new();
805                let got = state.navigate(head, selection.clone(), &store, forward);
806                match (&want, &got) {
807                    (Some((id, range)), Some(got_range)) => {
808                        assert_eq!(got_range, range, "trial {trial} fwd={forward}: range");
809                        assert_eq!(state.active, Some(*id), "trial {trial} fwd={forward}: active id");
810                        assert_eq!(
811                            state.active_start,
812                            Some(range.start),
813                            "trial {trial} fwd={forward}: active_start tracks the pick"
814                        );
815                    }
816                    (None, None) => {
817                        assert_eq!(state.active, None, "trial {trial} fwd={forward}");
818                        assert_eq!(state.active_start, None, "trial {trial} fwd={forward}");
819                    }
820                    _ => panic!("trial {trial} fwd={forward}: {want:?} vs {got:?}"),
821                }
822            }
823        }
824    }
825}