1use crate::pattern::SearchPattern;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
19pub enum Direction {
20 #[default]
22 Forward,
23 Backward,
25}
26
27impl Direction {
28 #[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#[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 #[must_use]
48 pub const fn len(&self) -> usize {
49 self.end - self.start
50 }
51
52 #[must_use]
54 pub const fn is_empty(&self) -> bool {
55 self.start == self.end
56 }
57
58 #[must_use]
62 pub const fn contains(&self, offset: usize) -> bool {
63 offset >= self.start && offset < self.end
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum Wrapped {
72 No,
73 AtBottom,
75 AtTop,
77}
78
79impl Wrapped {
80 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct Step {
94 pub target: SearchMatch,
95 pub index: usize,
98 pub wrapped: Wrapped,
99}
100
101#[must_use]
107pub fn find_all(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
108 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 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#[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#[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#[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 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 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 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 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 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 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 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 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 assert_eq!(seen, vec![2, 8, 14]);
442 assert_eq!(step(&m, at, Direction::Forward).unwrap().target.start, 2);
444 }
445}