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 schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17/// Which way a search runs.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
19pub enum Direction {
20    /// `/` — toward the end of the buffer.
21    #[default]
22    Forward,
23    /// `?` — toward the start of the buffer.
24    Backward,
25}
26
27impl Direction {
28    /// The opposite direction — what `N` does to `n`.
29    #[must_use]
30    pub const fn reversed(self) -> Self {
31        match self {
32            Self::Forward => Self::Backward,
33            Self::Backward => Self::Forward,
34        }
35    }
36}
37
38/// One match, in **char** offsets, half-open `[start, end)`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
40pub struct SearchMatch {
41    pub start: usize,
42    pub end: usize,
43}
44
45impl SearchMatch {
46    /// Char length of the match. Zero for a zero-width match (`/x*`).
47    #[must_use]
48    pub const fn len(&self) -> usize {
49        self.end - self.start
50    }
51
52    /// Whether this is a zero-width match.
53    #[must_use]
54    pub const fn is_empty(&self) -> bool {
55        self.start == self.end
56    }
57
58    /// Whether `offset` falls inside this match — what the renderer asks when
59    /// deciding to highlight a cell. Zero-width matches contain nothing, so
60    /// they never highlight.
61    #[must_use]
62    pub const fn contains(&self, offset: usize) -> bool {
63        offset >= self.start && offset < self.end
64    }
65}
66
67/// Whether stepping to a match ran off the end and came back around. vim
68/// reports this ("search hit BOTTOM, continuing at TOP") and so do we — a wrap
69/// that happens silently is how you lose your place in a large file.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum Wrapped {
72    No,
73    /// Ran past the end, resumed at the top.
74    AtBottom,
75    /// Ran past the start, resumed at the bottom.
76    AtTop,
77}
78
79impl Wrapped {
80    /// The message vim prints, or `None` when nothing wrapped.
81    #[must_use]
82    pub const fn message(self) -> Option<&'static str> {
83        match self {
84            Self::No => None,
85            Self::AtBottom => Some("search hit BOTTOM, continuing at TOP"),
86            Self::AtTop => Some("search hit TOP, continuing at BOTTOM"),
87        }
88    }
89}
90
91/// Where a step landed.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct Step {
94    pub target: SearchMatch,
95    /// Index of `target` within the full match list — drives vim's `[3/17]`
96    /// match-count display.
97    pub index: usize,
98    pub wrapped: Wrapped,
99}
100
101/// Every match of `pattern` in `text`, in ascending order, non-overlapping.
102///
103/// Returns char offsets. An empty result means "no matches", which callers
104/// should surface as vim's `E486: Pattern not found` rather than treating as a
105/// no-op.
106#[must_use]
107pub fn find_all(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
108    // One pass to build the byte->char map, so the whole conversion is O(n)
109    // rather than O(n*m) from repeated `text[..b].chars().count()` calls. On a
110    // large buffer with many matches that difference is the whole frame budget.
111    let mut byte_to_char = vec![0usize; text.len() + 1];
112    for (char_idx, (byte_idx, _)) in text.char_indices().enumerate() {
113        byte_to_char[byte_idx] = char_idx;
114    }
115    byte_to_char[text.len()] = text.chars().count();
116    // Interior bytes of a multi-byte char are never match boundaries (regex
117    // only reports char-aligned offsets), so leaving them 0 is safe — but fill
118    // them forward anyway so a future caller cannot trip over a stale zero.
119    let mut last = 0;
120    for slot in &mut byte_to_char {
121        if *slot == 0 && last != 0 {
122            *slot = last;
123        } else {
124            last = *slot;
125        }
126    }
127
128    pattern
129        .regex()
130        .find_iter(text)
131        .map(|m| SearchMatch { start: byte_to_char[m.start()], end: byte_to_char[m.end()] })
132        .collect()
133}
134
135/// Step from `from` (a char offset, normally the cursor) to the next match in
136/// `direction`, wrapping around the buffer.
137///
138/// vim semantics, deliberately:
139/// - Forward finds the first match starting **strictly after** `from`, so `n`
140///   on top of a match advances instead of re-finding the same one.
141/// - Backward finds the last match starting **strictly before** `from`.
142/// - With no match ahead, it wraps and reports [`Wrapped`].
143/// - A single match always resolves to itself, reporting a wrap.
144#[must_use]
145pub fn step(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
146    if matches.is_empty() {
147        return None;
148    }
149    match direction {
150        Direction::Forward => matches
151            .iter()
152            .position(|m| m.start > from)
153            .map(|i| Step { target: matches[i], index: i, wrapped: Wrapped::No })
154            .or(Some(Step { target: matches[0], index: 0, wrapped: Wrapped::AtBottom })),
155        Direction::Backward => matches
156            .iter()
157            .rposition(|m| m.start < from)
158            .map(|i| Step { target: matches[i], index: i, wrapped: Wrapped::No })
159            .or_else(|| {
160                let i = matches.len() - 1;
161                Some(Step { target: matches[i], index: i, wrapped: Wrapped::AtTop })
162            }),
163    }
164}
165
166/// Like [`step`], but a match starting exactly **at** `from` counts as a hit.
167///
168/// This is what incremental search needs and `n` must not have. Typing `/foo`
169/// while the cursor already sits on a `foo` should light up *that* `foo`;
170/// pressing `n` on the same `foo` must move to the next one. The difference is
171/// one comparison, and getting it wrong is invisible until the cursor happens
172/// to rest on a match.
173///
174/// `from.saturating_sub(1)` is not a substitute: at offset 0 it saturates back
175/// to 0, so a match at 0 stays unreachable — the exact case that fails on the
176/// first line of a file.
177#[must_use]
178pub fn step_inclusive(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
179    if matches.is_empty() {
180        return None;
181    }
182    match direction {
183        Direction::Forward => matches
184            .iter()
185            .position(|m| m.start >= from)
186            .map(|i| Step { target: matches[i], index: i, wrapped: Wrapped::No })
187            .or(Some(Step { target: matches[0], index: 0, wrapped: Wrapped::AtBottom })),
188        Direction::Backward => matches
189            .iter()
190            .rposition(|m| m.start <= from)
191            .map(|i| Step { target: matches[i], index: i, wrapped: Wrapped::No })
192            .or_else(|| {
193                let i = matches.len() - 1;
194                Some(Step { target: matches[i], index: i, wrapped: Wrapped::AtTop })
195            }),
196    }
197}
198
199/// The word under (or immediately after) `cursor` — what `*` and `#` search
200/// for.
201///
202/// vim's rule, matched here: if the cursor is not on a word character, scan
203/// forward on the current line for one. Returns `None` when the rest of the
204/// line has no word, which is when vim beeps and does nothing.
205#[must_use]
206pub fn word_at(text: &str, cursor: usize) -> Option<String> {
207    let chars: Vec<char> = text.chars().collect();
208    if chars.is_empty() {
209        return None;
210    }
211    let is_word = |c: char| c.is_alphanumeric() || c == '_';
212
213    // Scan forward for a word char, stopping at the line end like vim does.
214    let mut i = cursor.min(chars.len().saturating_sub(1));
215    while i < chars.len() && !is_word(chars[i]) {
216        if chars[i] == '\n' {
217            return None;
218        }
219        i += 1;
220    }
221    if i >= chars.len() {
222        return None;
223    }
224    let mut start = i;
225    while start > 0 && is_word(chars[start - 1]) {
226        start -= 1;
227    }
228    let mut end = i;
229    while end < chars.len() && is_word(chars[end]) {
230        end += 1;
231    }
232    Some(chars[start..end].iter().collect())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::pattern::CaseMode;
239
240    fn pat(p: &str) -> SearchPattern {
241        SearchPattern::compile(p, CaseMode::Sensitive).unwrap()
242    }
243
244    #[test]
245    fn finds_every_occurrence_in_order() {
246        let m = find_all("foo bar foo baz foo", &pat("foo"));
247        assert_eq!(m.len(), 3);
248        assert_eq!(m[0], SearchMatch { start: 0, end: 3 });
249        assert_eq!(m[1], SearchMatch { start: 8, end: 11 });
250        assert_eq!(m[2], SearchMatch { start: 16, end: 19 });
251    }
252
253    #[test]
254    fn offsets_are_chars_not_bytes() {
255        // "héllo foo" — é is 2 bytes, so a byte-offset bug puts `foo` at 7.
256        let text = "héllo foo";
257        let m = find_all(text, &pat("foo"));
258        assert_eq!(m.len(), 1);
259        assert_eq!(m[0].start, 6, "char offset; byte offset would be 7");
260        // Prove it by slicing the way the buffer would.
261        let got: String = text.chars().skip(m[0].start).take(m[0].len()).collect();
262        assert_eq!(got, "foo");
263    }
264
265    #[test]
266    fn multibyte_heavy_text_stays_aligned() {
267        let text = "日本語 foo 日本語 foo";
268        let m = find_all(text, &pat("foo"));
269        assert_eq!(m.len(), 2);
270        for mm in &m {
271            let got: String = text.chars().skip(mm.start).take(mm.len()).collect();
272            assert_eq!(got, "foo");
273        }
274    }
275
276    #[test]
277    fn no_matches_is_empty_not_a_panic() {
278        assert!(find_all("abc", &pat("zzz")).is_empty());
279        assert!(step(&[], 0, Direction::Forward).is_none());
280    }
281
282    #[test]
283    fn forward_advances_past_a_match_the_cursor_sits_on() {
284        let m = find_all("foo foo foo", &pat("foo"));
285        // Cursor at 0 is ON the first match; `n` must go to the second.
286        let s = step(&m, 0, Direction::Forward).unwrap();
287        assert_eq!(s.target.start, 4);
288        assert_eq!(s.index, 1);
289        assert_eq!(s.wrapped, Wrapped::No);
290    }
291
292    #[test]
293    fn forward_wraps_at_the_bottom_and_says_so() {
294        let m = find_all("foo foo", &pat("foo"));
295        let s = step(&m, 100, Direction::Forward).unwrap();
296        assert_eq!(s.target.start, 0);
297        assert_eq!(s.wrapped, Wrapped::AtBottom);
298        assert!(s.wrapped.message().unwrap().contains("BOTTOM"));
299    }
300
301    #[test]
302    fn backward_finds_the_previous_match() {
303        let m = find_all("foo foo foo", &pat("foo"));
304        let s = step(&m, 8, Direction::Backward).unwrap();
305        assert_eq!(s.target.start, 4);
306        assert_eq!(s.wrapped, Wrapped::No);
307    }
308
309    #[test]
310    fn backward_wraps_at_the_top_and_says_so() {
311        let m = find_all("foo foo", &pat("foo"));
312        let s = step(&m, 0, Direction::Backward).unwrap();
313        assert_eq!(s.target.start, 4);
314        assert_eq!(s.wrapped, Wrapped::AtTop);
315        assert!(s.wrapped.message().unwrap().contains("TOP"));
316    }
317
318    #[test]
319    fn a_lone_match_resolves_to_itself_by_wrapping() {
320        let m = find_all("hello foo world", &pat("foo"));
321        assert_eq!(m.len(), 1);
322        for dir in [Direction::Forward, Direction::Backward] {
323            let s = step(&m, m[0].start, dir).unwrap();
324            assert_eq!(s.target, m[0], "single match must resolve to itself ({dir:?})");
325            assert_ne!(s.wrapped, Wrapped::No, "and must report the wrap");
326        }
327    }
328
329    #[test]
330    fn step_inclusive_finds_a_match_starting_at_the_cursor() {
331        let m = find_all("foo foo foo", &pat("foo"));
332        // The distinction that matters: exclusive `step` skips the match under
333        // the cursor (correct for `n`), inclusive keeps it (correct for
334        // incremental search).
335        assert_eq!(step(&m, 0, Direction::Forward).unwrap().target.start, 4);
336        assert_eq!(step_inclusive(&m, 0, Direction::Forward).unwrap().target.start, 0);
337    }
338
339    #[test]
340    fn step_inclusive_at_offset_zero_is_reachable() {
341        // Regression: the original preview used `from.saturating_sub(1)`, which
342        // saturates to 0 and therefore could never reach a match AT 0 — broken
343        // precisely on the first line of a file.
344        let m = find_all("foo bar", &pat("foo"));
345        let s = step_inclusive(&m, 0, Direction::Forward).unwrap();
346        assert_eq!(s.target.start, 0);
347        assert_eq!(s.wrapped, Wrapped::No, "reaching it must not count as a wrap");
348    }
349
350    #[test]
351    fn step_inclusive_backward_also_accepts_the_cursor_position() {
352        let m = find_all("foo foo foo", &pat("foo"));
353        assert_eq!(step(&m, 8, Direction::Backward).unwrap().target.start, 4);
354        assert_eq!(step_inclusive(&m, 8, Direction::Backward).unwrap().target.start, 8);
355    }
356
357    #[test]
358    fn step_inclusive_on_no_matches_is_none() {
359        assert!(step_inclusive(&[], 0, Direction::Forward).is_none());
360    }
361
362    #[test]
363    fn direction_reverses() {
364        assert_eq!(Direction::Forward.reversed(), Direction::Backward);
365        assert_eq!(Direction::Backward.reversed(), Direction::Forward);
366    }
367
368    #[test]
369    fn zero_width_matches_terminate_and_never_highlight() {
370        // `x*` matches empty at every position — a naive scanner loops forever.
371        let m = find_all("abc", &pat("x*"));
372        assert!(!m.is_empty());
373        assert!(m.iter().all(SearchMatch::is_empty));
374        assert!(!m[0].contains(0), "a zero-width match highlights nothing");
375    }
376
377    #[test]
378    fn contains_is_half_open() {
379        let m = SearchMatch { start: 2, end: 5 };
380        assert!(!m.contains(1));
381        assert!(m.contains(2));
382        assert!(m.contains(4));
383        assert!(!m.contains(5), "end is exclusive");
384    }
385
386    #[test]
387    fn word_at_reads_the_whole_word_from_inside_it() {
388        assert_eq!(word_at("hello world", 2).as_deref(), Some("hello"));
389        assert_eq!(word_at("hello world", 0).as_deref(), Some("hello"));
390        assert_eq!(word_at("hello world", 4).as_deref(), Some("hello"));
391        assert_eq!(word_at("hello world", 8).as_deref(), Some("world"));
392    }
393
394    #[test]
395    fn word_at_scans_forward_from_whitespace_like_vim() {
396        assert_eq!(word_at("  hello", 0).as_deref(), Some("hello"));
397    }
398
399    #[test]
400    fn word_at_stops_at_the_line_end() {
401        // vim does not jump to the next line looking for a word.
402        assert_eq!(word_at("   \nhello", 0), None);
403    }
404
405    #[test]
406    fn word_at_includes_underscores_and_digits() {
407        assert_eq!(word_at("foo_bar99 x", 0).as_deref(), Some("foo_bar99"));
408    }
409
410    #[test]
411    fn word_at_on_empty_text_is_none() {
412        assert_eq!(word_at("", 0), None);
413    }
414
415    #[test]
416    fn case_insensitive_search_finds_mixed_case() {
417        let p = SearchPattern::compile("foo", CaseMode::Ignore).unwrap();
418        assert_eq!(find_all("Foo FOO foo", &p).len(), 3);
419    }
420
421    #[test]
422    fn smartcase_capital_narrows_the_result_set() {
423        let loose = SearchPattern::compile("foo", CaseMode::Smart).unwrap();
424        let tight = SearchPattern::compile("Foo", CaseMode::Smart).unwrap();
425        assert_eq!(find_all("Foo FOO foo", &loose).len(), 3);
426        assert_eq!(find_all("Foo FOO foo", &tight).len(), 1);
427    }
428
429    #[test]
430    fn stepping_forward_through_every_match_returns_to_the_start() {
431        let text = "a foo b foo c foo d";
432        let m = find_all(text, &pat("foo"));
433        let mut at = 0;
434        let mut seen = vec![];
435        for _ in 0..m.len() {
436            let s = step(&m, at, Direction::Forward).unwrap();
437            seen.push(s.target.start);
438            at = s.target.start;
439        }
440        // From 0 (before the first match at 2): 2, 8, 14 — a full cycle.
441        assert_eq!(seen, vec![2, 8, 14]);
442        // One more wraps back to the first.
443        assert_eq!(step(&m, at, Direction::Forward).unwrap().target.start, 2);
444    }
445}