1use crate::pattern::SearchPattern;
14use escriba_memori::{Bound, Offset, Ruler};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
20pub enum Direction {
21 #[default]
23 Forward,
24 Backward,
26}
27
28impl Direction {
29 #[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#[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 #[must_use]
49 pub const fn len(&self) -> usize {
50 self.end - self.start
51 }
52
53 #[must_use]
55 pub const fn is_empty(&self) -> bool {
56 self.start == self.end
57 }
58
59 #[must_use]
63 pub const fn contains(&self, offset: usize) -> bool {
64 offset >= self.start && offset < self.end
65 }
66}
67
68pub use escriba_memori::Wrapped;
73
74#[must_use]
80pub const fn wrap_message(w: Wrapped) -> Option<&'static str> {
81 match w {
82 Wrapped::No => None,
83 Wrapped::AtBottom => Some("search hit BOTTOM, continuing at TOP"),
84 Wrapped::AtTop => Some("search hit TOP, continuing at BOTTOM"),
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct Step {
91 pub target: SearchMatch,
92 pub index: usize,
95 pub wrapped: Wrapped,
96}
97
98#[must_use]
104pub fn find_all(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
105 let ruler = Ruler::new(text);
118 let mut scan = ruler.ascending();
119
120 pattern
121 .regex()
122 .find_iter(text)
123 .map(|m| SearchMatch {
124 start: scan.to_chars(Offset::new(m.start())).raw(),
125 end: scan.to_chars(Offset::new(m.end())).raw(),
126 })
127 .collect()
128}
129
130#[must_use]
140pub fn step(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
141 step_bounded(matches, from, direction, Bound::Exclusive)
142}
143
144#[must_use]
154pub fn step_inclusive(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
155 step_bounded(matches, from, direction, Bound::Inclusive)
156}
157
158#[must_use]
174pub fn step_bounded(
175 matches: &[SearchMatch],
176 from: usize,
177 direction: Direction,
178 bound: Bound,
179) -> Option<Step> {
180 if matches.is_empty() {
181 return None;
182 }
183 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
184 let forward = matches!(direction, Direction::Forward);
185
186 let landing = bound.step_wrapping(&starts, from, forward)?;
189 Some(Step {
190 target: matches[landing.index],
191 index: landing.index,
192 wrapped: landing.wrapped,
193 })
194}
195
196#[must_use]
197pub fn word_at(text: &str, cursor: usize) -> Option<String> {
198 let chars: Vec<char> = text.chars().collect();
199 if chars.is_empty() {
200 return None;
201 }
202 let is_word = |c: char| c.is_alphanumeric() || c == '_';
203
204 let mut i = cursor.min(chars.len().saturating_sub(1));
206 while i < chars.len() && !is_word(chars[i]) {
207 if chars[i] == '\n' {
208 return None;
209 }
210 i += 1;
211 }
212 if i >= chars.len() {
213 return None;
214 }
215 let mut start = i;
216 while start > 0 && is_word(chars[start - 1]) {
217 start -= 1;
218 }
219 let mut end = i;
220 while end < chars.len() && is_word(chars[end]) {
221 end += 1;
222 }
223 Some(chars[start..end].iter().collect())
224}
225
226pub const MAX_COUNT: usize = 99;
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum MatchCount {
250 Idle,
252 None,
255 Exact { current: usize, total: usize },
257 Capped { current: usize },
259}
260
261impl MatchCount {
262 #[must_use]
268 pub const fn new(index: usize, total: usize) -> Self {
269 if total == 0 || index >= total {
270 return Self::None;
271 }
272 if total > MAX_COUNT {
273 return Self::Capped { current: index + 1 };
274 }
275 Self::Exact {
276 current: index + 1,
277 total,
278 }
279 }
280
281 #[must_use]
283 pub const fn is_idle(self) -> bool {
284 matches!(self, Self::Idle)
285 }
286
287 pub fn render_into(self, out: &mut String) {
293 match self {
294 Self::Idle => {}
295 Self::None => out.push_str("[0/0]"),
296 Self::Exact { current, total } => {
297 out.push('[');
298 push_usize(out, current);
299 out.push('/');
300 push_usize(out, total);
301 out.push(']');
302 }
303 Self::Capped { current } => {
304 out.push('[');
305 push_usize(out, current);
306 out.push_str("/>");
307 push_usize(out, MAX_COUNT);
308 out.push(']');
309 }
310 }
311 }
312}
313
314fn push_usize(out: &mut String, mut n: usize) {
316 if n == 0 {
317 out.push('0');
318 return;
319 }
320 let mut buf = [0u8; 20];
321 let mut i = buf.len();
322 while n > 0 {
323 i -= 1;
324 buf[i] = b'0' + u8::try_from(n % 10).unwrap_or(0);
325 n /= 10;
326 }
327 out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("?"));
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::pattern::CaseMode;
335
336 fn pat(p: &str) -> SearchPattern {
337 SearchPattern::compile(p, CaseMode::Sensitive).unwrap()
338 }
339
340 #[test]
341 fn finds_every_occurrence_in_order() {
342 let m = find_all("foo bar foo baz foo", &pat("foo"));
343 assert_eq!(m.len(), 3);
344 assert_eq!(m[0], SearchMatch { start: 0, end: 3 });
345 assert_eq!(m[1], SearchMatch { start: 8, end: 11 });
346 assert_eq!(m[2], SearchMatch { start: 16, end: 19 });
347 }
348
349 #[test]
350 fn offsets_are_chars_not_bytes() {
351 let text = "héllo foo";
353 let m = find_all(text, &pat("foo"));
354 assert_eq!(m.len(), 1);
355 assert_eq!(m[0].start, 6, "char offset; byte offset would be 7");
356 let got: String = text.chars().skip(m[0].start).take(m[0].len()).collect();
358 assert_eq!(got, "foo");
359 }
360
361 #[test]
362 fn multibyte_heavy_text_stays_aligned() {
363 let text = "日本語 foo 日本語 foo";
364 let m = find_all(text, &pat("foo"));
365 assert_eq!(m.len(), 2);
366 for mm in &m {
367 let got: String = text.chars().skip(mm.start).take(mm.len()).collect();
368 assert_eq!(got, "foo");
369 }
370 }
371
372 #[test]
373 fn no_matches_is_empty_not_a_panic() {
374 assert!(find_all("abc", &pat("zzz")).is_empty());
375 assert!(step(&[], 0, Direction::Forward).is_none());
376 }
377
378 #[test]
379 fn forward_advances_past_a_match_the_cursor_sits_on() {
380 let m = find_all("foo foo foo", &pat("foo"));
381 let s = step(&m, 0, Direction::Forward).unwrap();
383 assert_eq!(s.target.start, 4);
384 assert_eq!(s.index, 1);
385 assert_eq!(s.wrapped, Wrapped::No);
386 }
387
388 #[test]
389 fn forward_wraps_at_the_bottom_and_says_so() {
390 let m = find_all("foo foo", &pat("foo"));
391 let s = step(&m, 100, Direction::Forward).unwrap();
392 assert_eq!(s.target.start, 0);
393 assert_eq!(s.wrapped, Wrapped::AtBottom);
394 assert!(wrap_message(s.wrapped).unwrap().contains("BOTTOM"));
395 }
396
397 #[test]
398 fn backward_finds_the_previous_match() {
399 let m = find_all("foo foo foo", &pat("foo"));
400 let s = step(&m, 8, Direction::Backward).unwrap();
401 assert_eq!(s.target.start, 4);
402 assert_eq!(s.wrapped, Wrapped::No);
403 }
404
405 #[test]
406 fn backward_wraps_at_the_top_and_says_so() {
407 let m = find_all("foo foo", &pat("foo"));
408 let s = step(&m, 0, Direction::Backward).unwrap();
409 assert_eq!(s.target.start, 4);
410 assert_eq!(s.wrapped, Wrapped::AtTop);
411 assert!(wrap_message(s.wrapped).unwrap().contains("TOP"));
412 }
413
414 #[test]
415 fn a_lone_match_resolves_to_itself_by_wrapping() {
416 let m = find_all("hello foo world", &pat("foo"));
417 assert_eq!(m.len(), 1);
418 for dir in [Direction::Forward, Direction::Backward] {
419 let s = step(&m, m[0].start, dir).unwrap();
420 assert_eq!(
421 s.target, m[0],
422 "single match must resolve to itself ({dir:?})"
423 );
424 assert_ne!(s.wrapped, Wrapped::No, "and must report the wrap");
425 }
426 }
427
428 #[test]
429 fn step_inclusive_finds_a_match_starting_at_the_cursor() {
430 let m = find_all("foo foo foo", &pat("foo"));
431 assert_eq!(step(&m, 0, Direction::Forward).unwrap().target.start, 4);
435 assert_eq!(
436 step_inclusive(&m, 0, Direction::Forward)
437 .unwrap()
438 .target
439 .start,
440 0
441 );
442 }
443
444 #[test]
445 fn step_inclusive_at_offset_zero_is_reachable() {
446 let m = find_all("foo bar", &pat("foo"));
450 let s = step_inclusive(&m, 0, Direction::Forward).unwrap();
451 assert_eq!(s.target.start, 0);
452 assert_eq!(
453 s.wrapped,
454 Wrapped::No,
455 "reaching it must not count as a wrap"
456 );
457 }
458
459 #[test]
460 fn step_inclusive_backward_also_accepts_the_cursor_position() {
461 let m = find_all("foo foo foo", &pat("foo"));
462 assert_eq!(step(&m, 8, Direction::Backward).unwrap().target.start, 4);
463 assert_eq!(
464 step_inclusive(&m, 8, Direction::Backward)
465 .unwrap()
466 .target
467 .start,
468 8
469 );
470 }
471
472 #[test]
473 fn step_inclusive_on_no_matches_is_none() {
474 assert!(step_inclusive(&[], 0, Direction::Forward).is_none());
475 }
476
477 #[test]
478 fn direction_reverses() {
479 assert_eq!(Direction::Forward.reversed(), Direction::Backward);
480 assert_eq!(Direction::Backward.reversed(), Direction::Forward);
481 }
482
483 #[test]
484 fn zero_width_matches_terminate_and_never_highlight() {
485 let m = find_all("abc", &pat("x*"));
487 assert!(!m.is_empty());
488 assert!(m.iter().all(SearchMatch::is_empty));
489 assert!(!m[0].contains(0), "a zero-width match highlights nothing");
490 }
491
492 #[test]
493 fn contains_is_half_open() {
494 let m = SearchMatch { start: 2, end: 5 };
495 assert!(!m.contains(1));
496 assert!(m.contains(2));
497 assert!(m.contains(4));
498 assert!(!m.contains(5), "end is exclusive");
499 }
500
501 #[test]
502 fn word_at_reads_the_whole_word_from_inside_it() {
503 assert_eq!(word_at("hello world", 2).as_deref(), Some("hello"));
504 assert_eq!(word_at("hello world", 0).as_deref(), Some("hello"));
505 assert_eq!(word_at("hello world", 4).as_deref(), Some("hello"));
506 assert_eq!(word_at("hello world", 8).as_deref(), Some("world"));
507 }
508
509 #[test]
510 fn word_at_scans_forward_from_whitespace_like_vim() {
511 assert_eq!(word_at(" hello", 0).as_deref(), Some("hello"));
512 }
513
514 #[test]
515 fn word_at_stops_at_the_line_end() {
516 assert_eq!(word_at(" \nhello", 0), None);
518 }
519
520 #[test]
521 fn word_at_includes_underscores_and_digits() {
522 assert_eq!(word_at("foo_bar99 x", 0).as_deref(), Some("foo_bar99"));
523 }
524
525 #[test]
526 fn word_at_on_empty_text_is_none() {
527 assert_eq!(word_at("", 0), None);
528 }
529
530 #[test]
531 fn case_insensitive_search_finds_mixed_case() {
532 let p = SearchPattern::compile("foo", CaseMode::Ignore).unwrap();
533 assert_eq!(find_all("Foo FOO foo", &p).len(), 3);
534 }
535
536 #[test]
537 fn smartcase_capital_narrows_the_result_set() {
538 let loose = SearchPattern::compile("foo", CaseMode::Smart).unwrap();
539 let tight = SearchPattern::compile("Foo", CaseMode::Smart).unwrap();
540 assert_eq!(find_all("Foo FOO foo", &loose).len(), 3);
541 assert_eq!(find_all("Foo FOO foo", &tight).len(), 1);
542 }
543
544 #[test]
545 fn stepping_forward_through_every_match_returns_to_the_start() {
546 let text = "a foo b foo c foo d";
547 let m = find_all(text, &pat("foo"));
548 let mut at = 0;
549 let mut seen = vec![];
550 for _ in 0..m.len() {
551 let s = step(&m, at, Direction::Forward).unwrap();
552 seen.push(s.target.start);
553 at = s.target.start;
554 }
555 assert_eq!(seen, vec![2, 8, 14]);
557 assert_eq!(step(&m, at, Direction::Forward).unwrap().target.start, 2);
559 }
560
561 #[test]
562 fn the_ruler_scan_agrees_with_the_hand_rolled_map() {
563 fn oracle(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
568 let mut byte_to_char = vec![0usize; text.len() + 1];
569 for (char_idx, (byte_idx, _)) in text.char_indices().enumerate() {
570 byte_to_char[byte_idx] = char_idx;
571 }
572 byte_to_char[text.len()] = text.chars().count();
573 let mut last = 0;
574 for slot in &mut byte_to_char {
575 if *slot == 0 && last != 0 {
576 *slot = last;
577 } else {
578 last = *slot;
579 }
580 }
581 pattern
582 .regex()
583 .find_iter(text)
584 .map(|m| SearchMatch {
585 start: byte_to_char[m.start()],
586 end: byte_to_char[m.end()],
587 })
588 .collect()
589 }
590
591 let cases: &[(&str, &str)] = &[
595 ("alpha bravo alpha", "alpha"),
596 ("héllo wörld héllo", "héllo"),
597 ("日本語 foo 日本語 foo", "foo"),
598 ("🔥a🔥a🔥", "a"),
599 ("aaa", "a"),
600 ("abc", "abc"),
601 ("", "x"),
602 ("no match here", "zzz"),
603 ("x🔥y", r"\w"),
604 ("one\ntwo\none", "one"),
605 ];
606
607 for (text, pat) in cases {
608 let p = SearchPattern::compile(pat, CaseMode::Sensitive).expect("compiles");
609 assert_eq!(
610 find_all(text, &p),
611 oracle(text, &p),
612 "memori scan disagreed with the hand-rolled map on {text:?} / {pat:?}",
613 );
614 }
615 }
616
617 #[test]
618 fn find_all_still_reports_char_offsets_after_the_retrofit() {
619 let p = SearchPattern::compile("foo", CaseMode::Sensitive).expect("compiles");
622 let got = find_all("héllo foo", &p);
623 assert_eq!(got.len(), 1);
624 assert_eq!(got[0].start, 6, "chars, not the byte offset 7");
625 }
626}