Skip to main content

hjkl_buffer/
search.rs

1//! `/` search over a [`crate::Buffer`].
2//!
3//! Pattern compiles once via [`Buffer::set_search_pattern`]; matches
4//! are computed lazily per row and cached against `dirty_gen` so a
5//! steady-state buffer doesn't re-scan rows on every `n` / `N`.
6
7use regex::Regex;
8
9use crate::{Buffer, Position};
10
11/// Per-row match cache. `gen` is the [`Buffer::dirty_gen`] at the
12/// time the row was scanned; mismatch means the row's text changed
13/// underneath us and we re-scan.
14#[derive(Debug, Default, Clone)]
15pub(crate) struct SearchState {
16    pub(crate) pattern: Option<Regex>,
17    /// `matches[row]` is the cached `(byte_start, byte_end)` runs on
18    /// that row, captured at `gen[row]`. Length grows lazily as
19    /// rows get queried.
20    pub(crate) matches: Vec<Vec<(usize, usize)>>,
21    pub(crate) generations: Vec<u64>,
22}
23
24impl SearchState {
25    pub(crate) fn new() -> Self {
26        Self::default()
27    }
28
29    pub(crate) fn set_pattern(&mut self, re: Option<Regex>) {
30        self.pattern = re;
31        self.matches.clear();
32        self.generations.clear();
33    }
34
35    /// Refresh `matches[row]` if either the row's gen has rolled or
36    /// we never scanned it. Returns the cached slice on success.
37    pub(crate) fn matches_for(
38        &mut self,
39        row: usize,
40        line: &str,
41        dirty_gen: u64,
42    ) -> &[(usize, usize)] {
43        let Some(ref re) = self.pattern else {
44            return &[];
45        };
46        if self.matches.len() <= row {
47            self.matches.resize_with(row + 1, Vec::new);
48            self.generations.resize(row + 1, u64::MAX);
49        }
50        if self.generations[row] != dirty_gen {
51            self.matches[row] = re.find_iter(line).map(|m| (m.start(), m.end())).collect();
52            self.generations[row] = dirty_gen;
53        }
54        &self.matches[row]
55    }
56}
57
58impl Buffer {
59    /// Set the active search pattern. Pass `None` to clear.
60    /// Subsequent [`Buffer::search_forward`] / `search_backward`
61    /// calls use the new pattern; `n` / `N` repeat the last set
62    /// pattern.
63    pub fn set_search_pattern(&mut self, re: Option<Regex>) {
64        self.search_state_mut().set_pattern(re);
65    }
66
67    pub fn search_pattern(&self) -> Option<&Regex> {
68        self.search_state().pattern.as_ref()
69    }
70
71    /// Move the cursor to the next match starting from (or just
72    /// after, when `skip_current = true`) the cursor. Wraps end-of-
73    /// buffer to row 0. Returns `true` when a match was found.
74    pub fn search_forward(&mut self, skip_current: bool) -> bool {
75        if self.search_pattern().is_none() {
76            return false;
77        }
78        let cursor = self.cursor();
79        let start_byte = self
80            .line(cursor.row)
81            .map(|l| cursor.byte_offset(l))
82            .unwrap_or(0);
83        // Search current row from cursor onward.
84        if let Some(pos) =
85            self.find_match_in_row(cursor.row, start_byte, skip_current, /*forward=*/ true)
86        {
87            self.set_cursor(pos);
88            self.ensure_cursor_visible();
89            return true;
90        }
91        // Scan rows after cursor, then wrap to rows before.
92        let total = self.row_count();
93        for offset in 1..=total {
94            let row = (cursor.row + offset) % total;
95            if let Some(pos) = self.find_match_in_row(row, 0, false, true) {
96                self.set_cursor(pos);
97                self.ensure_cursor_visible();
98                return true;
99            }
100            if row == cursor.row {
101                break;
102            }
103        }
104        false
105    }
106
107    /// Move to the previous match. Symmetric with `search_forward`
108    /// but walks rows backwards and picks the rightmost match in
109    /// each row.
110    pub fn search_backward(&mut self, skip_current: bool) -> bool {
111        if self.search_pattern().is_none() {
112            return false;
113        }
114        let cursor = self.cursor();
115        let cursor_byte = self
116            .line(cursor.row)
117            .map(|l| cursor.byte_offset(l))
118            .unwrap_or(0);
119        if let Some(pos) = self.find_match_in_row(
120            cursor.row,
121            cursor_byte,
122            skip_current,
123            /*forward=*/ false,
124        ) {
125            self.set_cursor(pos);
126            self.ensure_cursor_visible();
127            return true;
128        }
129        let total = self.row_count();
130        for offset in 1..=total {
131            // Walk backwards with wrap.
132            let row = (cursor.row + total - offset) % total;
133            if let Some(pos) =
134                self.find_match_in_row(row, usize::MAX, false, /*forward=*/ false)
135            {
136                self.set_cursor(pos);
137                self.ensure_cursor_visible();
138                return true;
139            }
140            if row == cursor.row {
141                break;
142            }
143        }
144        false
145    }
146
147    /// Match positions on `row` as `(byte_start, byte_end)`. Used
148    /// by the render layer to paint search-match bg.
149    pub fn search_matches(&mut self, row: usize) -> Vec<(usize, usize)> {
150        let line = self.line(row).unwrap_or("").to_string();
151        let dgen = self.dirty_gen();
152        self.search_state_mut()
153            .matches_for(row, &line, dgen)
154            .to_vec()
155    }
156
157    fn find_match_in_row(
158        &mut self,
159        row: usize,
160        anchor_byte: usize,
161        skip_current: bool,
162        forward: bool,
163    ) -> Option<Position> {
164        let line = self.line(row)?.to_string();
165        let dgen = self.dirty_gen();
166        let matches = self.search_state_mut().matches_for(row, &line, dgen);
167        if matches.is_empty() {
168            return None;
169        }
170        let m = if forward {
171            matches
172                .iter()
173                .find(|(s, _)| {
174                    if skip_current {
175                        *s > anchor_byte
176                    } else {
177                        *s >= anchor_byte
178                    }
179                })
180                .copied()
181        } else {
182            matches
183                .iter()
184                .rev()
185                .find(|(s, _)| {
186                    if skip_current {
187                        *s < anchor_byte
188                    } else {
189                        *s <= anchor_byte
190                    }
191                })
192                .copied()
193        }?;
194        // Convert byte offset back to char column.
195        let col = line[..m.0].chars().count();
196        Some(Position::new(row, col))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn re(pat: &str) -> Regex {
205        Regex::new(pat).unwrap()
206    }
207
208    #[test]
209    fn no_pattern_returns_false() {
210        let mut b = Buffer::from_str("anything");
211        assert!(!b.search_forward(false));
212        assert!(!b.search_backward(false));
213    }
214
215    #[test]
216    fn forward_finds_first_match_on_cursor_row() {
217        let mut b = Buffer::from_str("foo bar foo baz");
218        b.set_search_pattern(Some(re("foo")));
219        assert!(b.search_forward(false));
220        assert_eq!(b.cursor(), Position::new(0, 0));
221    }
222
223    #[test]
224    fn forward_skip_current_walks_past() {
225        let mut b = Buffer::from_str("foo bar foo baz");
226        b.set_search_pattern(Some(re("foo")));
227        b.search_forward(false);
228        b.search_forward(true);
229        assert_eq!(b.cursor(), Position::new(0, 8));
230    }
231
232    #[test]
233    fn forward_wraps_to_top() {
234        let mut b = Buffer::from_str("zzz\nfoo");
235        b.set_cursor(Position::new(1, 2));
236        b.set_search_pattern(Some(re("zzz")));
237        assert!(b.search_forward(true));
238        assert_eq!(b.cursor(), Position::new(0, 0));
239    }
240
241    #[test]
242    fn backward_picks_rightmost_match_on_row() {
243        let mut b = Buffer::from_str("foo bar foo baz");
244        b.set_cursor(Position::new(0, 14));
245        b.set_search_pattern(Some(re("foo")));
246        assert!(b.search_backward(true));
247        assert_eq!(b.cursor(), Position::new(0, 8));
248        b.search_backward(true);
249        assert_eq!(b.cursor(), Position::new(0, 0));
250    }
251
252    #[test]
253    fn backward_wraps_to_bottom() {
254        let mut b = Buffer::from_str("foo\nzzz");
255        b.set_cursor(Position::new(0, 0));
256        b.set_search_pattern(Some(re("zzz")));
257        assert!(b.search_backward(true));
258        assert_eq!(b.cursor(), Position::new(1, 0));
259    }
260
261    #[test]
262    fn no_match_returns_false_and_keeps_cursor() {
263        let mut b = Buffer::from_str("alpha beta gamma");
264        b.set_cursor(Position::new(0, 5));
265        b.set_search_pattern(Some(re("zzz")));
266        let before = b.cursor();
267        assert!(!b.search_forward(false));
268        assert_eq!(b.cursor(), before);
269    }
270
271    #[test]
272    fn cache_invalidates_after_edit() {
273        use crate::Edit;
274        let mut b = Buffer::from_str("foo bar");
275        b.set_search_pattern(Some(re("bar")));
276        let initial = b.search_matches(0);
277        assert_eq!(initial, vec![(4, 7)]);
278        b.apply_edit(Edit::InsertStr {
279            at: Position::new(0, 0),
280            text: "XX ".into(),
281        });
282        let after = b.search_matches(0);
283        assert_eq!(after, vec![(7, 10)]);
284    }
285
286    #[test]
287    fn unicode_match_columns_are_charwise() {
288        let mut b = Buffer::from_str("tablé foo");
289        b.set_search_pattern(Some(re("foo")));
290        assert!(b.search_forward(false));
291        // 'foo' starts at char index 6 ("tablé " = 6 chars).
292        assert_eq!(b.cursor(), Position::new(0, 6));
293    }
294}