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: ®ex::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: ®ex::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}