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