Skip to main content

escriba_search/
engine.rs

1//! Finding matches, and stepping between them.
2//!
3//! # Offsets
4//!
5//! The `regex` crate reports **byte** offsets; escriba's buffer addresses text
6//! in **char** offsets (`Buffer::char_to_position`). Mixing the two is silently
7//! correct for ASCII and silently wrong the moment a document contains a
8//! non-ASCII character — the classic bug that survives every test written in
9//! English. So the conversion happens exactly once, here, at the boundary, and
10//! [`SearchMatch`] is char-offset by construction. Nothing downstream ever sees
11//! a byte offset.
12
13use crate::pattern::SearchPattern;
14use escriba_memori::{Bound, Offset, Ruler};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18/// Which way a search runs.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
20pub enum Direction {
21    /// `/` — toward the end of the buffer.
22    #[default]
23    Forward,
24    /// `?` — toward the start of the buffer.
25    Backward,
26}
27
28impl Direction {
29    /// The opposite direction — what `N` does to `n`.
30    #[must_use]
31    pub const fn reversed(self) -> Self {
32        match self {
33            Self::Forward => Self::Backward,
34            Self::Backward => Self::Forward,
35        }
36    }
37}
38
39/// One match, in **char** offsets, half-open `[start, end)`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
41pub struct SearchMatch {
42    pub start: usize,
43    pub end: usize,
44}
45
46impl SearchMatch {
47    /// Char length of the match. Zero for a zero-width match (`/x*`).
48    #[must_use]
49    pub const fn len(&self) -> usize {
50        self.end - self.start
51    }
52
53    /// Whether this is a zero-width match.
54    #[must_use]
55    pub const fn is_empty(&self) -> bool {
56        self.start == self.end
57    }
58
59    /// Whether `offset` falls inside this match — what the renderer asks when
60    /// deciding to highlight a cell. Zero-width matches contain nothing, so
61    /// they never highlight.
62    #[must_use]
63    pub const fn contains(&self, offset: usize) -> bool {
64        offset >= self.start && offset < self.end
65    }
66}
67
68// `Wrapped` moved to `escriba-memori` when result-list navigation became its
69// second consumer. The wrap and the fact that it is ANNOUNCED are one
70// behaviour; two copies are two chances for one of them to go quiet.
71// Re-exported, so no caller had to move.
72pub use escriba_memori::Wrapped;
73
74/// vim's wrap messages.
75///
76/// These stay here rather than in memori: the WRAP is a property of stepping,
77/// the WORDS are vim's, and a fleet primitive should not carry one editor's
78/// phrasing.
79#[must_use]
80pub const fn wrap_message(w: Wrapped) -> Option<&'static str> {
81    match w {
82        Wrapped::No => None,
83        Wrapped::AtBottom => Some("search hit BOTTOM, continuing at TOP"),
84        Wrapped::AtTop => Some("search hit TOP, continuing at BOTTOM"),
85    }
86}
87
88/// Where a step landed.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct Step {
91    pub target: SearchMatch,
92    /// Index of `target` within the full match list — drives vim's `[3/17]`
93    /// match-count display.
94    pub index: usize,
95    pub wrapped: Wrapped,
96}
97
98/// Every match of `pattern` in `text`, in ascending order, non-overlapping.
99///
100/// Returns char offsets. An empty result means "no matches", which callers
101/// should surface as vim's `E486: Pattern not found` rather than treating as a
102/// no-op.
103#[must_use]
104pub fn find_all(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
105    // `regex` reports BYTE offsets; `SearchMatch` is in CHARS. The conversion
106    // rides a `memori` ascending scan: the offsets arrive in order (matches
107    // are non-overlapping and ascending, and `start <= end` within each), so a
108    // forward-only cursor converts all of them in O(n + m) total with O(1)
109    // extra memory.
110    //
111    // This replaces a dense `byte_to_char` map that was correct but expensive:
112    // it allocated and zeroed a `usize` PER DOCUMENT BYTE — 8 MiB of scratch
113    // for a 1 MiB buffer — on every keystroke of an incremental search. The
114    // scan allocates nothing. `the_ruler_scan_agrees_with_the_hand_rolled_map`
115    // pins the two implementations equal so the swap cannot have changed an
116    // answer.
117    let ruler = Ruler::new(text);
118    let mut scan = ruler.ascending();
119
120    pattern
121        .regex()
122        .find_iter(text)
123        .map(|m| SearchMatch {
124            start: scan.to_chars(Offset::new(m.start())).raw(),
125            end: scan.to_chars(Offset::new(m.end())).raw(),
126        })
127        .collect()
128}
129
130/// Step from `from` (a char offset, normally the cursor) to the next match in
131/// `direction`, wrapping around the buffer.
132///
133/// vim semantics, deliberately:
134/// - Forward finds the first match starting **strictly after** `from`, so `n`
135///   on top of a match advances instead of re-finding the same one.
136/// - Backward finds the last match starting **strictly before** `from`.
137/// - With no match ahead, it wraps and reports [`Wrapped`].
138/// - A single match always resolves to itself, reporting a wrap.
139#[must_use]
140pub fn step(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
141    step_bounded(matches, from, direction, Bound::Exclusive)
142}
143
144/// Like [`step`], but a match starting exactly **at** `from` counts as a hit.
145///
146/// This is what incremental search needs and `n` must not have. Typing `/foo`
147/// while the cursor already sits on a `foo` should light up *that* `foo`;
148/// pressing `n` on the same `foo` must move to the next one.
149///
150/// `from.saturating_sub(1)` is not a substitute: at offset 0 it saturates back
151/// to 0, so a match at 0 stays unreachable — the exact case that fails on the
152/// first line of a file.
153#[must_use]
154pub fn step_inclusive(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
155    step_bounded(matches, from, direction, Bound::Inclusive)
156}
157
158/// Step to the next match, with the endpoint rule stated rather than chosen by
159/// picking a function name.
160///
161/// [`step`] and [`step_inclusive`] are now one-line delegations to this. They
162/// stay because they are published API with their own tests (★★ MODULARIZE,
163/// DON'T DELETE) and because `step`/`step_inclusive` read better at a call site
164/// that has no other reason to name a bound — but there is exactly ONE
165/// implementation, so the twins can no longer drift apart.
166///
167/// The difference between them used to be a single `>` versus `>=` duplicated
168/// across two nearly identical function bodies, which is precisely the shape
169/// that drifts. `memori::Bound` owns that comparison now, and
170/// `Bound::first_matching` contains no subtraction — so the "back up one to
171/// include the anchor" trick that made offset 0 unreachable has nowhere left
172/// to live.
173#[must_use]
174pub fn step_bounded(
175    matches: &[SearchMatch],
176    from: usize,
177    direction: Direction,
178    bound: Bound,
179) -> Option<Step> {
180    if matches.is_empty() {
181        return None;
182    }
183    let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
184    let forward = matches!(direction, Direction::Forward);
185
186    // An adapter now: the wrap, and saying so, live in
187    // `memori::Bound::step_wrapping`, shared with result-list navigation.
188    let landing = bound.step_wrapping(&starts, from, forward)?;
189    Some(Step {
190        target: matches[landing.index],
191        index: landing.index,
192        wrapped: landing.wrapped,
193    })
194}
195
196#[must_use]
197pub fn word_at(text: &str, cursor: usize) -> Option<String> {
198    let chars: Vec<char> = text.chars().collect();
199    if chars.is_empty() {
200        return None;
201    }
202    let is_word = |c: char| c.is_alphanumeric() || c == '_';
203
204    // Scan forward for a word char, stopping at the line end like vim does.
205    let mut i = cursor.min(chars.len().saturating_sub(1));
206    while i < chars.len() && !is_word(chars[i]) {
207        if chars[i] == '\n' {
208            return None;
209        }
210        i += 1;
211    }
212    if i >= chars.len() {
213        return None;
214    }
215    let mut start = i;
216    while start > 0 && is_word(chars[start - 1]) {
217        start -= 1;
218    }
219    let mut end = i;
220    while end < chars.len() && is_word(chars[end]) {
221        end += 1;
222    }
223    Some(chars[start..end].iter().collect())
224}
225
226/// Vim's default `maxcount` — a DISPLAY cap only.
227///
228/// Past this the ordinal stays exact and the denominator renders as `>99`. It
229/// does **not** bound the scan: [`find_all`] has no early exit, so counting a
230/// large buffer costs a full pass regardless of this constant. Bounding that
231/// work is the caller's job, not this value's.
232///
233/// The previous wording claimed counting was "bounded so one keystroke on a
234/// large buffer cannot become a full scan the user waits on", which was false
235/// in both halves — the scan is uncapped, and while a prompt was open it ran
236/// TWICE per status line.
237pub const MAX_COUNT: usize = 99;
238
239/// A match-count display — vim's `[3/17]`.
240///
241/// Both halves were already computed and both were thrown away: the numerator
242/// is [`Step::index`], the denominator is the length of [`find_all`]'s result.
243/// This type is what finally carries them to a status line.
244///
245/// The denominator is the load-bearing half. `[1/1]` says a rename is safe;
246/// `[1/240]` says narrow the pattern first. Without it the only way to learn
247/// how many matches exist is to press `n` until the view looks familiar.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum MatchCount {
250    /// No pattern armed and no prompt previewing — render nothing.
251    Idle,
252    /// A pattern exists but matches nothing. Renders `[0/0]`, which reports a
253    /// bad pattern while it is still being typed rather than after Enter.
254    None,
255    /// `[current/total]`, both exact. `current` is 1-based for display.
256    Exact { current: usize, total: usize },
257    /// More matches than [`MAX_COUNT`]; the ordinal is still exact.
258    Capped { current: usize },
259}
260
261impl MatchCount {
262    /// Build a count from a 0-based match index and a total.
263    ///
264    /// `index` is [`Step::index`]; `total` is `matches.len()`. An out-of-range
265    /// index yields [`MatchCount::None`] rather than a wrong ordinal — a count
266    /// that lies is worse than one that declines to answer.
267    #[must_use]
268    pub const fn new(index: usize, total: usize) -> Self {
269        if total == 0 || index >= total {
270            return Self::None;
271        }
272        if total > MAX_COUNT {
273            return Self::Capped { current: index + 1 };
274        }
275        Self::Exact {
276            current: index + 1,
277            total,
278        }
279    }
280
281    /// Is there anything to draw?
282    #[must_use]
283    pub const fn is_idle(self) -> bool {
284        matches!(self, Self::Idle)
285    }
286
287    /// Append the display form to `out`.
288    ///
289    /// Written with `push_str` rather than `format!` — the fleet's ★★ TYPED
290    /// EMISSION rule, and the same reason `pattern.rs::format_word_boundary`
291    /// is hand-built.
292    pub fn render_into(self, out: &mut String) {
293        match self {
294            Self::Idle => {}
295            Self::None => out.push_str("[0/0]"),
296            Self::Exact { current, total } => {
297                out.push('[');
298                push_usize(out, current);
299                out.push('/');
300                push_usize(out, total);
301                out.push(']');
302            }
303            Self::Capped { current } => {
304                out.push('[');
305                push_usize(out, current);
306                out.push_str("/>");
307                push_usize(out, MAX_COUNT);
308                out.push(']');
309            }
310        }
311    }
312}
313
314/// Decimal-append a `usize` without `format!`.
315fn push_usize(out: &mut String, mut n: usize) {
316    if n == 0 {
317        out.push('0');
318        return;
319    }
320    let mut buf = [0u8; 20];
321    let mut i = buf.len();
322    while n > 0 {
323        i -= 1;
324        buf[i] = b'0' + u8::try_from(n % 10).unwrap_or(0);
325        n /= 10;
326    }
327    // Every byte written is an ASCII digit, so this slice is valid UTF-8.
328    out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("?"));
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::pattern::CaseMode;
335
336    fn pat(p: &str) -> SearchPattern {
337        SearchPattern::compile(p, CaseMode::Sensitive).unwrap()
338    }
339
340    #[test]
341    fn finds_every_occurrence_in_order() {
342        let m = find_all("foo bar foo baz foo", &pat("foo"));
343        assert_eq!(m.len(), 3);
344        assert_eq!(m[0], SearchMatch { start: 0, end: 3 });
345        assert_eq!(m[1], SearchMatch { start: 8, end: 11 });
346        assert_eq!(m[2], SearchMatch { start: 16, end: 19 });
347    }
348
349    #[test]
350    fn offsets_are_chars_not_bytes() {
351        // "héllo foo" — é is 2 bytes, so a byte-offset bug puts `foo` at 7.
352        let text = "héllo foo";
353        let m = find_all(text, &pat("foo"));
354        assert_eq!(m.len(), 1);
355        assert_eq!(m[0].start, 6, "char offset; byte offset would be 7");
356        // Prove it by slicing the way the buffer would.
357        let got: String = text.chars().skip(m[0].start).take(m[0].len()).collect();
358        assert_eq!(got, "foo");
359    }
360
361    #[test]
362    fn multibyte_heavy_text_stays_aligned() {
363        let text = "日本語 foo 日本語 foo";
364        let m = find_all(text, &pat("foo"));
365        assert_eq!(m.len(), 2);
366        for mm in &m {
367            let got: String = text.chars().skip(mm.start).take(mm.len()).collect();
368            assert_eq!(got, "foo");
369        }
370    }
371
372    #[test]
373    fn no_matches_is_empty_not_a_panic() {
374        assert!(find_all("abc", &pat("zzz")).is_empty());
375        assert!(step(&[], 0, Direction::Forward).is_none());
376    }
377
378    #[test]
379    fn forward_advances_past_a_match_the_cursor_sits_on() {
380        let m = find_all("foo foo foo", &pat("foo"));
381        // Cursor at 0 is ON the first match; `n` must go to the second.
382        let s = step(&m, 0, Direction::Forward).unwrap();
383        assert_eq!(s.target.start, 4);
384        assert_eq!(s.index, 1);
385        assert_eq!(s.wrapped, Wrapped::No);
386    }
387
388    #[test]
389    fn forward_wraps_at_the_bottom_and_says_so() {
390        let m = find_all("foo foo", &pat("foo"));
391        let s = step(&m, 100, Direction::Forward).unwrap();
392        assert_eq!(s.target.start, 0);
393        assert_eq!(s.wrapped, Wrapped::AtBottom);
394        assert!(wrap_message(s.wrapped).unwrap().contains("BOTTOM"));
395    }
396
397    #[test]
398    fn backward_finds_the_previous_match() {
399        let m = find_all("foo foo foo", &pat("foo"));
400        let s = step(&m, 8, Direction::Backward).unwrap();
401        assert_eq!(s.target.start, 4);
402        assert_eq!(s.wrapped, Wrapped::No);
403    }
404
405    #[test]
406    fn backward_wraps_at_the_top_and_says_so() {
407        let m = find_all("foo foo", &pat("foo"));
408        let s = step(&m, 0, Direction::Backward).unwrap();
409        assert_eq!(s.target.start, 4);
410        assert_eq!(s.wrapped, Wrapped::AtTop);
411        assert!(wrap_message(s.wrapped).unwrap().contains("TOP"));
412    }
413
414    #[test]
415    fn a_lone_match_resolves_to_itself_by_wrapping() {
416        let m = find_all("hello foo world", &pat("foo"));
417        assert_eq!(m.len(), 1);
418        for dir in [Direction::Forward, Direction::Backward] {
419            let s = step(&m, m[0].start, dir).unwrap();
420            assert_eq!(
421                s.target, m[0],
422                "single match must resolve to itself ({dir:?})"
423            );
424            assert_ne!(s.wrapped, Wrapped::No, "and must report the wrap");
425        }
426    }
427
428    #[test]
429    fn step_inclusive_finds_a_match_starting_at_the_cursor() {
430        let m = find_all("foo foo foo", &pat("foo"));
431        // The distinction that matters: exclusive `step` skips the match under
432        // the cursor (correct for `n`), inclusive keeps it (correct for
433        // incremental search).
434        assert_eq!(step(&m, 0, Direction::Forward).unwrap().target.start, 4);
435        assert_eq!(
436            step_inclusive(&m, 0, Direction::Forward)
437                .unwrap()
438                .target
439                .start,
440            0
441        );
442    }
443
444    #[test]
445    fn step_inclusive_at_offset_zero_is_reachable() {
446        // Regression: the original preview used `from.saturating_sub(1)`, which
447        // saturates to 0 and therefore could never reach a match AT 0 — broken
448        // precisely on the first line of a file.
449        let m = find_all("foo bar", &pat("foo"));
450        let s = step_inclusive(&m, 0, Direction::Forward).unwrap();
451        assert_eq!(s.target.start, 0);
452        assert_eq!(
453            s.wrapped,
454            Wrapped::No,
455            "reaching it must not count as a wrap"
456        );
457    }
458
459    #[test]
460    fn step_inclusive_backward_also_accepts_the_cursor_position() {
461        let m = find_all("foo foo foo", &pat("foo"));
462        assert_eq!(step(&m, 8, Direction::Backward).unwrap().target.start, 4);
463        assert_eq!(
464            step_inclusive(&m, 8, Direction::Backward)
465                .unwrap()
466                .target
467                .start,
468            8
469        );
470    }
471
472    #[test]
473    fn step_inclusive_on_no_matches_is_none() {
474        assert!(step_inclusive(&[], 0, Direction::Forward).is_none());
475    }
476
477    #[test]
478    fn direction_reverses() {
479        assert_eq!(Direction::Forward.reversed(), Direction::Backward);
480        assert_eq!(Direction::Backward.reversed(), Direction::Forward);
481    }
482
483    #[test]
484    fn zero_width_matches_terminate_and_never_highlight() {
485        // `x*` matches empty at every position — a naive scanner loops forever.
486        let m = find_all("abc", &pat("x*"));
487        assert!(!m.is_empty());
488        assert!(m.iter().all(SearchMatch::is_empty));
489        assert!(!m[0].contains(0), "a zero-width match highlights nothing");
490    }
491
492    #[test]
493    fn contains_is_half_open() {
494        let m = SearchMatch { start: 2, end: 5 };
495        assert!(!m.contains(1));
496        assert!(m.contains(2));
497        assert!(m.contains(4));
498        assert!(!m.contains(5), "end is exclusive");
499    }
500
501    #[test]
502    fn word_at_reads_the_whole_word_from_inside_it() {
503        assert_eq!(word_at("hello world", 2).as_deref(), Some("hello"));
504        assert_eq!(word_at("hello world", 0).as_deref(), Some("hello"));
505        assert_eq!(word_at("hello world", 4).as_deref(), Some("hello"));
506        assert_eq!(word_at("hello world", 8).as_deref(), Some("world"));
507    }
508
509    #[test]
510    fn word_at_scans_forward_from_whitespace_like_vim() {
511        assert_eq!(word_at("  hello", 0).as_deref(), Some("hello"));
512    }
513
514    #[test]
515    fn word_at_stops_at_the_line_end() {
516        // vim does not jump to the next line looking for a word.
517        assert_eq!(word_at("   \nhello", 0), None);
518    }
519
520    #[test]
521    fn word_at_includes_underscores_and_digits() {
522        assert_eq!(word_at("foo_bar99 x", 0).as_deref(), Some("foo_bar99"));
523    }
524
525    #[test]
526    fn word_at_on_empty_text_is_none() {
527        assert_eq!(word_at("", 0), None);
528    }
529
530    #[test]
531    fn case_insensitive_search_finds_mixed_case() {
532        let p = SearchPattern::compile("foo", CaseMode::Ignore).unwrap();
533        assert_eq!(find_all("Foo FOO foo", &p).len(), 3);
534    }
535
536    #[test]
537    fn smartcase_capital_narrows_the_result_set() {
538        let loose = SearchPattern::compile("foo", CaseMode::Smart).unwrap();
539        let tight = SearchPattern::compile("Foo", CaseMode::Smart).unwrap();
540        assert_eq!(find_all("Foo FOO foo", &loose).len(), 3);
541        assert_eq!(find_all("Foo FOO foo", &tight).len(), 1);
542    }
543
544    #[test]
545    fn stepping_forward_through_every_match_returns_to_the_start() {
546        let text = "a foo b foo c foo d";
547        let m = find_all(text, &pat("foo"));
548        let mut at = 0;
549        let mut seen = vec![];
550        for _ in 0..m.len() {
551            let s = step(&m, at, Direction::Forward).unwrap();
552            seen.push(s.target.start);
553            at = s.target.start;
554        }
555        // From 0 (before the first match at 2): 2, 8, 14 — a full cycle.
556        assert_eq!(seen, vec![2, 8, 14]);
557        // One more wraps back to the first.
558        assert_eq!(step(&m, at, Direction::Forward).unwrap().target.start, 2);
559    }
560
561    #[test]
562    fn the_ruler_scan_agrees_with_the_hand_rolled_map() {
563        // The differential test that licenses the retrofit. The oracle is the
564        // dense `byte_to_char` map `find_all` used to build, reconstructed
565        // here verbatim — if the memori scan ever disagrees with it on any
566        // match boundary of any corpus entry, the swap changed an answer.
567        fn oracle(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
568            let mut byte_to_char = vec![0usize; text.len() + 1];
569            for (char_idx, (byte_idx, _)) in text.char_indices().enumerate() {
570                byte_to_char[byte_idx] = char_idx;
571            }
572            byte_to_char[text.len()] = text.chars().count();
573            let mut last = 0;
574            for slot in &mut byte_to_char {
575                if *slot == 0 && last != 0 {
576                    *slot = last;
577                } else {
578                    last = *slot;
579                }
580            }
581            pattern
582                .regex()
583                .find_iter(text)
584                .map(|m| SearchMatch {
585                    start: byte_to_char[m.start()],
586                    end: byte_to_char[m.end()],
587                })
588                .collect()
589        }
590
591        // Corpora chosen so a byte/char confusion cannot hide: multi-byte
592        // chars BEFORE, BETWEEN and AFTER matches, plus adjacent and
593        // whole-text matches.
594        let cases: &[(&str, &str)] = &[
595            ("alpha bravo alpha", "alpha"),
596            ("héllo wörld héllo", "héllo"),
597            ("日本語 foo 日本語 foo", "foo"),
598            ("🔥a🔥a🔥", "a"),
599            ("aaa", "a"),
600            ("abc", "abc"),
601            ("", "x"),
602            ("no match here", "zzz"),
603            ("x🔥y", r"\w"),
604            ("one\ntwo\none", "one"),
605        ];
606
607        for (text, pat) in cases {
608            let p = SearchPattern::compile(pat, CaseMode::Sensitive).expect("compiles");
609            assert_eq!(
610                find_all(text, &p),
611                oracle(text, &p),
612                "memori scan disagreed with the hand-rolled map on {text:?} / {pat:?}",
613            );
614        }
615    }
616
617    #[test]
618    fn find_all_still_reports_char_offsets_after_the_retrofit() {
619        // The property the retrofit exists to preserve, asserted directly
620        // rather than only through the oracle.
621        let p = SearchPattern::compile("foo", CaseMode::Sensitive).expect("compiles");
622        let got = find_all("héllo foo", &p);
623        assert_eq!(got.len(), 1);
624        assert_eq!(got[0].start, 6, "chars, not the byte offset 7");
625    }
626}