hjkl_engine/search.rs
1//! Engine-owned search state + execution helpers.
2//!
3//! Patch 0.0.35 step 1 of the 33-method classification rollout
4//! (see `DESIGN_33_METHOD_CLASSIFICATION.md`). The pattern, per-row
5//! match cache, and `wrapscan` flag previously lived on
6//! [`hjkl_buffer::Buffer`] (private `SearchState`). Moving the FSM
7//! state out of the buffer keeps multi-window hosts from sharing the
8//! "current search" across panes that happen to share content.
9//!
10//! The buffer keeps `Search::find_next` / `Search::find_prev` (the
11//! SPEC trait surface — pure observers, caller owns the regex). This
12//! module composes those primitives with the Editor-owned
13//! [`SearchState`] to drive `n` / `N` / `*` / `#` / `/` / `?`.
14//!
15//! 0.0.37: the buffer-inherent `search_forward` / `search_backward`
16//! / `search_matches` / `set_search_pattern` / `search_pattern` /
17//! `set_search_wrap` / `search_wraps` accessors are removed. Search
18//! state lives on `Editor::search_state`, the rendering path
19//! (`BufferView`) takes the active `&Regex` as a parameter, and the
20//! `Search` trait impl always wraps (engine controls non-wrap
21//! semantics).
22
23use regex::Regex;
24
25use crate::types::{Cursor, Query, Search};
26
27/// Per-row match cache keyed against the buffer's `dirty_gen`. Live
28/// alongside the active pattern so re-running `n` doesn't re-scan
29/// rows the buffer hasn't touched.
30#[derive(Debug, Clone, Default)]
31pub struct SearchState {
32 /// Active pattern, if any. `None` clears highlighting and makes
33 /// `n` / `N` no-op until the next `/` / `?` commit.
34 pub pattern: Option<Regex>,
35 /// `true` for `/`, `false` for `?` — drives `n` vs `N` direction.
36 /// Mirrors `vim.last_search_forward`; consolidated so future
37 /// patches can drop the duplicate.
38 pub forward: bool,
39 /// `matches[row]` is the `(byte_start, byte_end)` runs cached on
40 /// `row`, captured at `gen[row]`. Length grows lazily.
41 pub matches: Vec<Vec<(usize, usize)>>,
42 /// Per-row generation tag. When the buffer's `dirty_gen` for a
43 /// row diverges, the row gets re-scanned on next access.
44 pub generations: Vec<u64>,
45 /// Wrap past buffer ends. Mirrors `Settings::wrapscan`.
46 pub wrap_around: bool,
47}
48
49impl SearchState {
50 /// Empty state — no pattern, forward direction, wraps.
51 pub fn new() -> Self {
52 Self {
53 pattern: None,
54 forward: true,
55 matches: Vec::new(),
56 generations: Vec::new(),
57 wrap_around: true,
58 }
59 }
60
61 /// Replace the active pattern. Drops the cached match runs so
62 /// the next access re-scans against the new regex.
63 pub fn set_pattern(&mut self, re: Option<Regex>) {
64 self.pattern = re;
65 self.matches.clear();
66 self.generations.clear();
67 }
68
69 /// Refresh `matches[row]` if either the row's gen has rolled or
70 /// we never scanned it. Returns the cached slice.
71 pub fn matches_for(&mut self, row: usize, line: &str, dirty_gen: u64) -> &[(usize, usize)] {
72 let Some(ref re) = self.pattern else {
73 return &[];
74 };
75 if self.matches.len() <= row {
76 self.matches.resize_with(row + 1, Vec::new);
77 self.generations.resize(row + 1, u64::MAX);
78 }
79 if self.generations[row] != dirty_gen {
80 self.matches[row] = re.find_iter(line).map(|m| (m.start(), m.end())).collect();
81 self.generations[row] = dirty_gen;
82 }
83 &self.matches[row]
84 }
85}
86
87/// Move the cursor to the next match starting from (or just after,
88/// when `skip_current = true`) the cursor. Wraps end-of-buffer to
89/// row 0 when `state.wrap_around`. Returns `true` when a match was
90/// found.
91///
92/// Pure observe + cursor mutation — no auto-scroll. The Editor's
93/// post-step `ensure_cursor_in_scrolloff` reapplies viewport
94/// follow.
95pub fn search_forward<B: Cursor + Query + Search>(
96 buf: &mut B,
97 state: &mut SearchState,
98 skip_current: bool,
99) -> bool {
100 let Some(re) = state.pattern.clone() else {
101 return false;
102 };
103 let cursor = buf.cursor();
104 let total = buf.line_count();
105 if total == 0 {
106 return false;
107 }
108 // To "skip the current cell", advance `from` one byte past the
109 // cursor before asking `find_next` for the at-or-after match.
110 // `pos_at_byte` clamps overflow to end-of-buffer so this is
111 // safe even when the cursor sits at the trailing edge.
112 let from = if skip_current {
113 let from_byte = buf.byte_offset(cursor);
114 buf.pos_at_byte(from_byte.saturating_add(1))
115 } else {
116 cursor
117 };
118 if let Some(range) = buf.find_next(from, &re) {
119 // Honour engine wrap policy explicitly. The buffer impl uses
120 // its own (deprecated) wrap flag; for new search state the
121 // engine SearchState is the source of truth.
122 if !state.wrap_around && range.start.line < cursor.line {
123 return false;
124 }
125 Cursor::set_cursor(buf, range.start);
126 return true;
127 }
128 false
129}
130
131/// Symmetric counterpart of [`search_forward`].
132pub fn search_backward<B: Cursor + Query + Search>(
133 buf: &mut B,
134 state: &mut SearchState,
135 skip_current: bool,
136) -> bool {
137 let Some(re) = state.pattern.clone() else {
138 return false;
139 };
140 let cursor = buf.cursor();
141 let total = buf.line_count();
142 if total == 0 {
143 return false;
144 }
145 // Buffer's `Search::find_prev` returns the at-or-before match
146 // for the anchor `from`. For `skip_current`, we want the
147 // rightmost match whose start is *strictly before* the cursor.
148 // Strategy: query find_prev(cursor); if the returned match
149 // covers/starts-at the cursor, step the anchor back one byte
150 // past that match's start and re-query so the next find_prev
151 // skips it. Otherwise the at-or-before match is already strictly
152 // before the cursor and we accept it.
153 let initial = buf.find_prev(cursor, &re);
154 let range = if skip_current {
155 match initial {
156 Some(m) if m.start == cursor => {
157 // Cursor sits exactly on a match start (typical post-
158 // commit state). Step past and re-query.
159 let cb = buf.byte_offset(m.start);
160 if cb == 0 {
161 // No earlier byte — fall through to wrap.
162 None
163 } else {
164 let anchor = buf.pos_at_byte(cb.saturating_sub(1));
165 buf.find_prev(anchor, &re)
166 }
167 }
168 other => other,
169 }
170 } else {
171 initial
172 };
173 if let Some(range) = range {
174 if !state.wrap_around && range.start.line > cursor.line {
175 return false;
176 }
177 Cursor::set_cursor(buf, range.start);
178 return true;
179 }
180 false
181}
182
183/// Match positions on `row` as `(byte_start, byte_end)`. Used by
184/// the engine's highlight pipeline. Reads through the cache so a
185/// steady-state buffer doesn't re-scan every frame.
186pub fn search_matches<B: Query>(
187 buf: &B,
188 state: &mut SearchState,
189 dirty_gen: u64,
190 row: usize,
191) -> Vec<(usize, usize)> {
192 if state.pattern.is_none() {
193 return Vec::new();
194 }
195 let line_count = buf.line_count() as usize;
196 if row >= line_count {
197 return Vec::new();
198 }
199 let line = buf.line(row as u32);
200 state.matches_for(row, line, dirty_gen).to_vec()
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::types::Pos;
207 use hjkl_buffer::Buffer;
208
209 fn re(pat: &str) -> Regex {
210 Regex::new(pat).unwrap()
211 }
212
213 #[test]
214 fn empty_state_no_match() {
215 let mut b = Buffer::from_str("anything");
216 let mut s = SearchState::new();
217 assert!(!search_forward(&mut b, &mut s, false));
218 assert!(!search_backward(&mut b, &mut s, false));
219 }
220
221 #[test]
222 fn forward_finds_first_match() {
223 let mut b = Buffer::from_str("foo bar foo baz");
224 let mut s = SearchState::new();
225 s.set_pattern(Some(re("foo")));
226 assert!(search_forward(&mut b, &mut s, false));
227 assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
228 }
229
230 #[test]
231 fn forward_skip_current_walks_past() {
232 let mut b = Buffer::from_str("foo bar foo baz");
233 let mut s = SearchState::new();
234 s.set_pattern(Some(re("foo")));
235 search_forward(&mut b, &mut s, false);
236 search_forward(&mut b, &mut s, true);
237 assert_eq!(Cursor::cursor(&b), Pos::new(0, 8));
238 }
239
240 #[test]
241 fn forward_wraps_to_top() {
242 let mut b = Buffer::from_str("zzz\nfoo");
243 // 0.0.37: wrap policy lives entirely on `SearchState::wrap_around`;
244 // the buffer-side `set_search_wrap` accessor is gone. Trait
245 // `find_next` always wraps; the engine search free function
246 // honours `s.wrap_around` directly.
247 Cursor::set_cursor(&mut b, Pos::new(1, 2));
248 let mut s = SearchState::new();
249 s.set_pattern(Some(re("zzz")));
250 s.wrap_around = true;
251 assert!(search_forward(&mut b, &mut s, true));
252 assert_eq!(Cursor::cursor(&b), Pos::new(0, 0));
253 }
254
255 #[test]
256 fn search_matches_caches_against_dirty_gen() {
257 let b = Buffer::from_str("foo bar");
258 let mut s = SearchState::new();
259 s.set_pattern(Some(re("bar")));
260 let dgen = b.dirty_gen();
261 let initial = search_matches(&b, &mut s, dgen, 0);
262 assert_eq!(initial, vec![(4, 7)]);
263 }
264}