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
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Wrapped {
73 No,
74 AtBottom,
76 AtTop,
78}
79
80impl Wrapped {
81 #[must_use]
83 pub const fn message(self) -> Option<&'static str> {
84 match self {
85 Self::No => None,
86 Self::AtBottom => Some("search hit BOTTOM, continuing at TOP"),
87 Self::AtTop => Some("search hit TOP, continuing at BOTTOM"),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct Step {
95 pub target: SearchMatch,
96 pub index: usize,
99 pub wrapped: Wrapped,
100}
101
102#[must_use]
108pub fn find_all(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
109 let ruler = Ruler::new(text);
122 let mut scan = ruler.ascending();
123
124 pattern
125 .regex()
126 .find_iter(text)
127 .map(|m| SearchMatch {
128 start: scan.to_chars(Offset::new(m.start())).raw(),
129 end: scan.to_chars(Offset::new(m.end())).raw(),
130 })
131 .collect()
132}
133
134#[must_use]
144pub fn step(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
145 step_bounded(matches, from, direction, Bound::Exclusive)
146}
147
148#[must_use]
158pub fn step_inclusive(matches: &[SearchMatch], from: usize, direction: Direction) -> Option<Step> {
159 step_bounded(matches, from, direction, Bound::Inclusive)
160}
161
162#[must_use]
178pub fn step_bounded(
179 matches: &[SearchMatch],
180 from: usize,
181 direction: Direction,
182 bound: Bound,
183) -> Option<Step> {
184 if matches.is_empty() {
185 return None;
186 }
187 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
188 let forward = matches!(direction, Direction::Forward);
189
190 match bound.first_matching(&starts, from, forward) {
191 Some(i) => Some(Step {
192 target: matches[i],
193 index: i,
194 wrapped: Wrapped::No,
195 }),
196 None if forward => Some(Step {
199 target: matches[0],
200 index: 0,
201 wrapped: Wrapped::AtBottom,
202 }),
203 None => {
204 let i = matches.len() - 1;
205 Some(Step {
206 target: matches[i],
207 index: i,
208 wrapped: Wrapped::AtTop,
209 })
210 }
211 }
212}
213
214#[must_use]
215pub fn word_at(text: &str, cursor: usize) -> Option<String> {
216 let chars: Vec<char> = text.chars().collect();
217 if chars.is_empty() {
218 return None;
219 }
220 let is_word = |c: char| c.is_alphanumeric() || c == '_';
221
222 let mut i = cursor.min(chars.len().saturating_sub(1));
224 while i < chars.len() && !is_word(chars[i]) {
225 if chars[i] == '\n' {
226 return None;
227 }
228 i += 1;
229 }
230 if i >= chars.len() {
231 return None;
232 }
233 let mut start = i;
234 while start > 0 && is_word(chars[start - 1]) {
235 start -= 1;
236 }
237 let mut end = i;
238 while end < chars.len() && is_word(chars[end]) {
239 end += 1;
240 }
241 Some(chars[start..end].iter().collect())
242}
243
244pub const MAX_COUNT: usize = 99;
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum MatchCount {
268 Idle,
270 None,
273 Exact { current: usize, total: usize },
275 Capped { current: usize },
277}
278
279impl MatchCount {
280 #[must_use]
286 pub const fn new(index: usize, total: usize) -> Self {
287 if total == 0 || index >= total {
288 return Self::None;
289 }
290 if total > MAX_COUNT {
291 return Self::Capped { current: index + 1 };
292 }
293 Self::Exact {
294 current: index + 1,
295 total,
296 }
297 }
298
299 #[must_use]
301 pub const fn is_idle(self) -> bool {
302 matches!(self, Self::Idle)
303 }
304
305 pub fn render_into(self, out: &mut String) {
311 match self {
312 Self::Idle => {}
313 Self::None => out.push_str("[0/0]"),
314 Self::Exact { current, total } => {
315 out.push('[');
316 push_usize(out, current);
317 out.push('/');
318 push_usize(out, total);
319 out.push(']');
320 }
321 Self::Capped { current } => {
322 out.push('[');
323 push_usize(out, current);
324 out.push_str("/>");
325 push_usize(out, MAX_COUNT);
326 out.push(']');
327 }
328 }
329 }
330}
331
332fn push_usize(out: &mut String, mut n: usize) {
334 if n == 0 {
335 out.push('0');
336 return;
337 }
338 let mut buf = [0u8; 20];
339 let mut i = buf.len();
340 while n > 0 {
341 i -= 1;
342 buf[i] = b'0' + u8::try_from(n % 10).unwrap_or(0);
343 n /= 10;
344 }
345 out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("?"));
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::pattern::CaseMode;
353
354 fn pat(p: &str) -> SearchPattern {
355 SearchPattern::compile(p, CaseMode::Sensitive).unwrap()
356 }
357
358 #[test]
359 fn finds_every_occurrence_in_order() {
360 let m = find_all("foo bar foo baz foo", &pat("foo"));
361 assert_eq!(m.len(), 3);
362 assert_eq!(m[0], SearchMatch { start: 0, end: 3 });
363 assert_eq!(m[1], SearchMatch { start: 8, end: 11 });
364 assert_eq!(m[2], SearchMatch { start: 16, end: 19 });
365 }
366
367 #[test]
368 fn offsets_are_chars_not_bytes() {
369 let text = "héllo foo";
371 let m = find_all(text, &pat("foo"));
372 assert_eq!(m.len(), 1);
373 assert_eq!(m[0].start, 6, "char offset; byte offset would be 7");
374 let got: String = text.chars().skip(m[0].start).take(m[0].len()).collect();
376 assert_eq!(got, "foo");
377 }
378
379 #[test]
380 fn multibyte_heavy_text_stays_aligned() {
381 let text = "日本語 foo 日本語 foo";
382 let m = find_all(text, &pat("foo"));
383 assert_eq!(m.len(), 2);
384 for mm in &m {
385 let got: String = text.chars().skip(mm.start).take(mm.len()).collect();
386 assert_eq!(got, "foo");
387 }
388 }
389
390 #[test]
391 fn no_matches_is_empty_not_a_panic() {
392 assert!(find_all("abc", &pat("zzz")).is_empty());
393 assert!(step(&[], 0, Direction::Forward).is_none());
394 }
395
396 #[test]
397 fn forward_advances_past_a_match_the_cursor_sits_on() {
398 let m = find_all("foo foo foo", &pat("foo"));
399 let s = step(&m, 0, Direction::Forward).unwrap();
401 assert_eq!(s.target.start, 4);
402 assert_eq!(s.index, 1);
403 assert_eq!(s.wrapped, Wrapped::No);
404 }
405
406 #[test]
407 fn forward_wraps_at_the_bottom_and_says_so() {
408 let m = find_all("foo foo", &pat("foo"));
409 let s = step(&m, 100, Direction::Forward).unwrap();
410 assert_eq!(s.target.start, 0);
411 assert_eq!(s.wrapped, Wrapped::AtBottom);
412 assert!(s.wrapped.message().unwrap().contains("BOTTOM"));
413 }
414
415 #[test]
416 fn backward_finds_the_previous_match() {
417 let m = find_all("foo foo foo", &pat("foo"));
418 let s = step(&m, 8, Direction::Backward).unwrap();
419 assert_eq!(s.target.start, 4);
420 assert_eq!(s.wrapped, Wrapped::No);
421 }
422
423 #[test]
424 fn backward_wraps_at_the_top_and_says_so() {
425 let m = find_all("foo foo", &pat("foo"));
426 let s = step(&m, 0, Direction::Backward).unwrap();
427 assert_eq!(s.target.start, 4);
428 assert_eq!(s.wrapped, Wrapped::AtTop);
429 assert!(s.wrapped.message().unwrap().contains("TOP"));
430 }
431
432 #[test]
433 fn a_lone_match_resolves_to_itself_by_wrapping() {
434 let m = find_all("hello foo world", &pat("foo"));
435 assert_eq!(m.len(), 1);
436 for dir in [Direction::Forward, Direction::Backward] {
437 let s = step(&m, m[0].start, dir).unwrap();
438 assert_eq!(
439 s.target, m[0],
440 "single match must resolve to itself ({dir:?})"
441 );
442 assert_ne!(s.wrapped, Wrapped::No, "and must report the wrap");
443 }
444 }
445
446 #[test]
447 fn step_inclusive_finds_a_match_starting_at_the_cursor() {
448 let m = find_all("foo foo foo", &pat("foo"));
449 assert_eq!(step(&m, 0, Direction::Forward).unwrap().target.start, 4);
453 assert_eq!(
454 step_inclusive(&m, 0, Direction::Forward)
455 .unwrap()
456 .target
457 .start,
458 0
459 );
460 }
461
462 #[test]
463 fn step_inclusive_at_offset_zero_is_reachable() {
464 let m = find_all("foo bar", &pat("foo"));
468 let s = step_inclusive(&m, 0, Direction::Forward).unwrap();
469 assert_eq!(s.target.start, 0);
470 assert_eq!(
471 s.wrapped,
472 Wrapped::No,
473 "reaching it must not count as a wrap"
474 );
475 }
476
477 #[test]
478 fn step_inclusive_backward_also_accepts_the_cursor_position() {
479 let m = find_all("foo foo foo", &pat("foo"));
480 assert_eq!(step(&m, 8, Direction::Backward).unwrap().target.start, 4);
481 assert_eq!(
482 step_inclusive(&m, 8, Direction::Backward)
483 .unwrap()
484 .target
485 .start,
486 8
487 );
488 }
489
490 #[test]
491 fn step_inclusive_on_no_matches_is_none() {
492 assert!(step_inclusive(&[], 0, Direction::Forward).is_none());
493 }
494
495 #[test]
496 fn direction_reverses() {
497 assert_eq!(Direction::Forward.reversed(), Direction::Backward);
498 assert_eq!(Direction::Backward.reversed(), Direction::Forward);
499 }
500
501 #[test]
502 fn zero_width_matches_terminate_and_never_highlight() {
503 let m = find_all("abc", &pat("x*"));
505 assert!(!m.is_empty());
506 assert!(m.iter().all(SearchMatch::is_empty));
507 assert!(!m[0].contains(0), "a zero-width match highlights nothing");
508 }
509
510 #[test]
511 fn contains_is_half_open() {
512 let m = SearchMatch { start: 2, end: 5 };
513 assert!(!m.contains(1));
514 assert!(m.contains(2));
515 assert!(m.contains(4));
516 assert!(!m.contains(5), "end is exclusive");
517 }
518
519 #[test]
520 fn word_at_reads_the_whole_word_from_inside_it() {
521 assert_eq!(word_at("hello world", 2).as_deref(), Some("hello"));
522 assert_eq!(word_at("hello world", 0).as_deref(), Some("hello"));
523 assert_eq!(word_at("hello world", 4).as_deref(), Some("hello"));
524 assert_eq!(word_at("hello world", 8).as_deref(), Some("world"));
525 }
526
527 #[test]
528 fn word_at_scans_forward_from_whitespace_like_vim() {
529 assert_eq!(word_at(" hello", 0).as_deref(), Some("hello"));
530 }
531
532 #[test]
533 fn word_at_stops_at_the_line_end() {
534 assert_eq!(word_at(" \nhello", 0), None);
536 }
537
538 #[test]
539 fn word_at_includes_underscores_and_digits() {
540 assert_eq!(word_at("foo_bar99 x", 0).as_deref(), Some("foo_bar99"));
541 }
542
543 #[test]
544 fn word_at_on_empty_text_is_none() {
545 assert_eq!(word_at("", 0), None);
546 }
547
548 #[test]
549 fn case_insensitive_search_finds_mixed_case() {
550 let p = SearchPattern::compile("foo", CaseMode::Ignore).unwrap();
551 assert_eq!(find_all("Foo FOO foo", &p).len(), 3);
552 }
553
554 #[test]
555 fn smartcase_capital_narrows_the_result_set() {
556 let loose = SearchPattern::compile("foo", CaseMode::Smart).unwrap();
557 let tight = SearchPattern::compile("Foo", CaseMode::Smart).unwrap();
558 assert_eq!(find_all("Foo FOO foo", &loose).len(), 3);
559 assert_eq!(find_all("Foo FOO foo", &tight).len(), 1);
560 }
561
562 #[test]
563 fn stepping_forward_through_every_match_returns_to_the_start() {
564 let text = "a foo b foo c foo d";
565 let m = find_all(text, &pat("foo"));
566 let mut at = 0;
567 let mut seen = vec![];
568 for _ in 0..m.len() {
569 let s = step(&m, at, Direction::Forward).unwrap();
570 seen.push(s.target.start);
571 at = s.target.start;
572 }
573 assert_eq!(seen, vec![2, 8, 14]);
575 assert_eq!(step(&m, at, Direction::Forward).unwrap().target.start, 2);
577 }
578
579 #[test]
580 fn the_ruler_scan_agrees_with_the_hand_rolled_map() {
581 fn oracle(text: &str, pattern: &SearchPattern) -> Vec<SearchMatch> {
586 let mut byte_to_char = vec![0usize; text.len() + 1];
587 for (char_idx, (byte_idx, _)) in text.char_indices().enumerate() {
588 byte_to_char[byte_idx] = char_idx;
589 }
590 byte_to_char[text.len()] = text.chars().count();
591 let mut last = 0;
592 for slot in &mut byte_to_char {
593 if *slot == 0 && last != 0 {
594 *slot = last;
595 } else {
596 last = *slot;
597 }
598 }
599 pattern
600 .regex()
601 .find_iter(text)
602 .map(|m| SearchMatch {
603 start: byte_to_char[m.start()],
604 end: byte_to_char[m.end()],
605 })
606 .collect()
607 }
608
609 let cases: &[(&str, &str)] = &[
613 ("alpha bravo alpha", "alpha"),
614 ("héllo wörld héllo", "héllo"),
615 ("日本語 foo 日本語 foo", "foo"),
616 ("🔥a🔥a🔥", "a"),
617 ("aaa", "a"),
618 ("abc", "abc"),
619 ("", "x"),
620 ("no match here", "zzz"),
621 ("x🔥y", r"\w"),
622 ("one\ntwo\none", "one"),
623 ];
624
625 for (text, pat) in cases {
626 let p = SearchPattern::compile(pat, CaseMode::Sensitive).expect("compiles");
627 assert_eq!(
628 find_all(text, &p),
629 oracle(text, &p),
630 "memori scan disagreed with the hand-rolled map on {text:?} / {pat:?}",
631 );
632 }
633 }
634
635 #[test]
636 fn find_all_still_reports_char_offsets_after_the_retrofit() {
637 let p = SearchPattern::compile("foo", CaseMode::Sensitive).expect("compiles");
640 let got = find_all("héllo foo", &p);
641 assert_eq!(got.len(), 1);
642 assert_eq!(got[0].start, 6, "chars, not the byte offset 7");
643 }
644}