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