1#![cfg_attr(docsrs, doc(cfg(feature = "search")))]
2#![allow(unused_imports)]
54use crate::minus_core::utils::{LinesRowMap, display, term};
55use crate::screen::Screen;
56use crate::{LineNumbers, PagerState};
57use crate::{error::MinusError, input::HashedEventRegister, minus_core::utils, screen};
58use crossterm::{
59 cursor::{self, MoveTo},
60 event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
61 style::Attribute,
62 terminal::{Clear, ClearType},
63};
64use regex::Regex;
65use std::borrow::Cow;
66use std::collections::BTreeSet;
67use std::{
68 convert::{TryFrom, TryInto},
69 fmt,
70 io::Write,
71 sync::LazyLock,
72 time::Duration,
73};
74
75use std::collections::hash_map::RandomState;
76
77static INVERT: LazyLock<String> = LazyLock::new(|| Attribute::Reverse.to_string());
78static NORMAL: LazyLock<String> = LazyLock::new(|| Attribute::NoReverse.to_string());
79static ANSI_REGEX: LazyLock<Regex> = LazyLock::new(|| {
80 Regex::new("[\\u001b\\u009b]\\[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]")
81 .unwrap()
82});
83
84static WORD: LazyLock<Regex> = LazyLock::new(|| {
85 Regex::new(r#"([\w_]+)|([-?~@#!$%^&*()-+={}\[\]:;\\|'/?<>.,"]+)|\W"#).unwrap()
86});
87
88#[derive(Clone, Copy, Debug, Default, Eq)]
89#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
90#[allow(clippy::module_name_repetitions)]
91pub enum SearchMode {
93 Forward,
95 Reverse,
97 #[default]
99 Unknown,
100}
101
102impl PartialEq for SearchMode {
103 fn eq(&self, other: &Self) -> bool {
104 core::mem::discriminant(self) == core::mem::discriminant(other)
105 }
106}
107
108#[allow(clippy::module_name_repetitions)]
116pub struct SearchOpts<'a> {
117 pub ev: Option<Event>,
119 pub string: String,
121 pub input_status: InputStatus,
123 pub cursor_position: u16,
126 pub search_mode: SearchMode,
128 pub word_index: Vec<u16>,
130 pub search_char: char,
132 pub rows: u16,
134 pub cols: u16,
136 pub incremental_search_options: Option<IncrementalSearchOpts<'a>>,
138 pub smart_case: bool,
140 compiled_regex: Option<Regex>,
141}
142
143pub struct IncrementalSearchOpts<'a> {
145 pub line_numbers: LineNumbers,
147 pub initial_upper_mark: usize,
149 pub screen: &'a Screen,
151 pub lines_to_row_map: &'a LinesRowMap,
153 pub initial_left_mark: usize,
155 cols: usize,
157 writable_rows: usize,
159}
160
161impl<'a> From<&'a PagerState> for IncrementalSearchOpts<'a> {
162 fn from(ps: &'a PagerState) -> Self {
163 Self {
164 line_numbers: ps.line_numbers,
165 initial_upper_mark: ps.upper_mark,
166 screen: &ps.screen,
167 lines_to_row_map: &ps.lines_to_row_map,
168 initial_left_mark: ps.left_mark,
169 cols: ps.cols,
170 writable_rows: ps.rows.saturating_sub(1),
171 }
172 }
173}
174
175impl IncrementalSearchOpts<'_> {
176 const fn line_number_digits(&self) -> usize {
177 utils::digits(self.screen.line_count())
178 }
179}
180
181#[allow(clippy::fallible_impl_from)]
182impl<'a> From<&'a PagerState> for SearchOpts<'a> {
183 fn from(ps: &'a PagerState) -> Self {
184 let search_char = if ps.search_state.search_mode == SearchMode::Forward {
185 '/'
186 } else if ps.search_state.search_mode == SearchMode::Reverse {
187 '?'
188 } else {
189 unreachable!();
190 };
191
192 let incremental_search_options = IncrementalSearchOpts::from(ps);
193
194 Self {
195 ev: None,
196 string: String::with_capacity(200),
197 input_status: InputStatus::Active,
198 cursor_position: 1,
199 word_index: Vec::with_capacity(200),
200 search_char,
201 rows: ps.rows.try_into().unwrap(),
202 cols: ps.cols.try_into().unwrap(),
203 incremental_search_options: Some(incremental_search_options),
204 smart_case: ps.search_state.smart_case,
205 compiled_regex: None,
206 search_mode: ps.search_state.search_mode,
207 }
208 }
209}
210
211#[derive(Debug, Eq, PartialEq, Clone)]
213pub enum InputStatus {
214 Confirmed,
216 Cancelled,
218 Active,
220}
221
222impl InputStatus {
223 #[must_use]
226 pub const fn done(&self) -> bool {
227 matches!(self, Self::Cancelled | Self::Confirmed)
228 }
229}
230
231pub(crate) struct FetchInputResult {
233 pub(crate) string: String,
235 pub(crate) compiled_regex: Option<Regex>,
237 pub(crate) smart_case: bool,
239}
240
241impl FetchInputResult {
242 const fn new_empty() -> Self {
245 Self {
246 string: String::new(),
247 compiled_regex: None,
248 smart_case: false,
249 }
250 }
251}
252
253pub(crate) fn compile_regex(query: &str, smart_case: bool) -> Option<Regex> {
254 if smart_case && !query.chars().any(char::is_uppercase) {
255 regex::RegexBuilder::new(query)
256 .case_insensitive(true)
257 .build()
258 .ok()
259 } else {
260 Regex::new(query).ok()
261 }
262}
263
264fn line_matches_query(line: &str, query: &Regex) -> bool {
265 let stripped = ANSI_REGEX.replace_all(line, "");
266 query.is_match(stripped.as_ref())
267}
268
269fn preview_line<'a>(
270 iso: &IncrementalSearchOpts<'a>,
271 query: &Regex,
272 line_idx: usize,
273 line: &'a str,
274 visible_lines: &mut Vec<Cow<'a, str>>,
275 upper_mark: &mut Option<usize>,
276 wrapped: bool,
277) {
278 if upper_mark.is_none() && !line_matches_query(line, query) {
280 return;
281 }
282
283 let row_start = *iso.lines_to_row_map.get(line_idx).unwrap_or(&0);
284 let mut match_row_idx = None;
285 let formatted_rows = screen::format_line(
286 line,
287 iso.line_number_digits(),
288 line_idx,
289 iso.line_numbers,
290 iso.cols,
291 iso.screen.line_wrapping,
292 );
293
294 let mut formatted_rows = screen::format_search_rows(formatted_rows, Some(query))
295 .enumerate()
296 .map(|(i, (sfr, is_match))| {
297 if is_match {
298 if wrapped || row_start + i >= iso.initial_upper_mark {
299 match_row_idx = Some(row_start + i);
300 }
301 Cow::Owned(sfr.to_string())
302 } else {
303 iso.screen.formatted_lines.get(row_start + i).map_or_else(
304 || Cow::Owned(sfr.to_string()),
305 |s| Cow::Borrowed(s.as_str()),
306 )
307 }
308 })
309 .collect::<Vec<Cow<str>>>();
310
311 if upper_mark.is_none() {
312 if match_row_idx.is_none() {
313 return;
314 }
315 let match_row_idx = match_row_idx.unwrap();
316 let skip_rows = match_row_idx.saturating_sub(row_start);
317 *upper_mark = Some(match_row_idx);
318 visible_lines.extend(formatted_rows.drain(skip_rows..));
319 } else {
320 visible_lines.append(&mut formatted_rows);
321 }
322
323 if visible_lines.len() >= iso.writable_rows {
324 visible_lines.truncate(iso.writable_rows);
325 }
326}
327
328fn incremental_preview<'a>(
329 iso: &IncrementalSearchOpts<'a>,
330 query: &'a Regex,
331) -> Option<Vec<Cow<'a, str>>> {
332 if iso.writable_rows == 0 {
333 return None;
334 }
335
336 let start_line_idx = iso
337 .lines_to_row_map
338 .row_to_line(iso.initial_upper_mark)?
339 .saturating_sub(1);
340
341 let mut visible_lines: Vec<Cow<str>> = Vec::with_capacity(iso.writable_rows);
342 let mut upper_mark = None;
343
344 for (line_idx, line) in iso
345 .screen
346 .orig_text
347 .lines()
348 .enumerate()
349 .skip(start_line_idx)
350 {
351 preview_line(
352 iso,
353 query,
354 line_idx,
355 line,
356 &mut visible_lines,
357 &mut upper_mark,
358 false,
359 );
360 if visible_lines.len() >= iso.writable_rows {
361 break;
362 }
363 }
364
365 if let Some(um) = upper_mark
370 && visible_lines.len() < iso.writable_rows
371 {
372 let start = iso
373 .screen
374 .formatted_lines_count()
375 .saturating_sub(iso.writable_rows);
376 let to_insert = um.saturating_sub(start);
377 let shift = visible_lines.len();
378
379 visible_lines.extend(
380 iso.screen
381 .formatted_lines
382 .iter()
383 .skip(start)
384 .take(to_insert)
385 .map(Into::into),
386 );
387 visible_lines.rotate_left(shift);
388 }
389
390 if upper_mark.is_none() {
391 for (line_idx, line) in iso
392 .screen
393 .orig_text
394 .lines()
395 .enumerate()
396 .take(start_line_idx)
397 {
398 preview_line(
399 iso,
400 query,
401 line_idx,
402 line,
403 &mut visible_lines,
404 &mut upper_mark,
405 true,
406 );
407 if visible_lines.len() >= iso.writable_rows {
408 break;
409 }
410 }
411 }
412
413 if upper_mark.is_some() {
414 Some(visible_lines)
415 } else {
416 None
417 }
418}
419
420fn run_incremental_search<'a, F, O>(
429 out: &mut O,
430 so: &'a SearchOpts<'a>,
431 incremental_search_condition: F,
432) -> crate::Result<()>
433where
434 O: Write,
435 F: Fn(&'a SearchOpts) -> bool,
436{
437 let Some(iso) = so.incremental_search_options.as_ref() else {
438 return Ok(());
439 };
440 let screen = iso.screen;
441 let line_numbers = iso.line_numbers;
442 let initial_upper_mark = iso.initial_upper_mark;
443 let initial_left_mark = iso.initial_left_mark;
444
445 let should_proceed = so.compiled_regex.is_some() && incremental_search_condition(so);
447
448 let reset_screen = |out: &mut O, so: &SearchOpts<'_>| -> crate::Result {
453 display::write_text_checked(
454 out,
455 &screen.formatted_lines,
456 initial_upper_mark,
457 so.rows.into(),
458 so.cols.into(),
459 screen.line_wrapping,
460 initial_left_mark,
461 line_numbers,
462 screen.line_count(),
463 )?;
464 Ok(())
465 };
466
467 if !should_proceed {
471 reset_screen(out, so)?;
472 return Ok(());
473 }
474
475 let query = so.compiled_regex.as_ref().unwrap();
476
477 let Some(visible_lines) = incremental_preview(iso, query) else {
478 reset_screen(out, so)?;
479 return Ok(());
480 };
481
482 display::write_text_checked(
484 out,
485 &visible_lines,
486 0,
487 so.rows.into(),
488 so.cols.into(),
489 iso.screen.line_wrapping,
490 iso.initial_left_mark,
491 iso.line_numbers,
492 iso.screen.line_count(),
493 )?;
494
495 Ok(())
496}
497
498#[allow(clippy::too_many_lines)]
502fn handle_key_press<O, F>(
503 out: &mut O,
504 so: &mut SearchOpts<'_>,
505 incremental_search_condition: F,
506) -> crate::Result
507where
508 O: Write,
509 F: Fn(&SearchOpts<'_>) -> bool,
510{
511 const FIRST_AVAILABLE_COLUMN: u16 = 1;
513 let last_available_column: u16 = so.string.len().saturating_add(1).try_into().unwrap();
514
515 if so.ev.is_none() {
517 return Ok(());
518 }
519
520 let populate_word_index = |so: &mut SearchOpts<'_>| {
521 so.word_index = WORD
522 .find_iter(&so.string)
523 .map(|c| c.start().saturating_add(1).try_into().unwrap())
524 .collect::<Vec<u16>>();
525 };
526
527 let refresh_display = |out: &mut O, so: &mut SearchOpts<'_>| -> Result<(), MinusError> {
528 so.compiled_regex = compile_regex(&so.string, so.smart_case);
530
531 run_incremental_search(out, so, incremental_search_condition)?;
532
533 term::move_cursor(out, 0, so.rows, false)?;
535 write!(
536 out,
537 "\r{}{}{}",
538 Clear(ClearType::CurrentLine),
539 so.search_char,
540 so.string,
541 )?;
542 Ok(())
543 };
544 match so.ev.as_ref().unwrap() {
545 Event::Key(KeyEvent { kind, .. }) if *kind != KeyEventKind::Press => (),
546 Event::Key(KeyEvent {
549 code: KeyCode::Esc,
550 modifiers: KeyModifiers::NONE,
551 ..
552 }) => {
553 so.string.clear();
554 so.input_status = InputStatus::Cancelled;
555 }
556 Event::Key(KeyEvent {
557 code: KeyCode::Backspace,
558 modifiers: KeyModifiers::NONE,
559 ..
560 }) => {
561 if so.cursor_position == FIRST_AVAILABLE_COLUMN {
564 return Ok(());
565 }
566 so.cursor_position = so.cursor_position.saturating_sub(1);
567 so.string
568 .remove(so.cursor_position.saturating_sub(1).into());
569 populate_word_index(so);
570 refresh_display(out, so)?;
572 term::move_cursor(out, so.cursor_position, so.rows, false)?;
573 out.flush()?;
574 }
575 Event::Key(KeyEvent {
576 code: KeyCode::Delete,
577 modifiers: KeyModifiers::NONE,
578 ..
579 }) => {
580 if so.cursor_position >= last_available_column {
583 return Ok(());
584 }
585 so.cursor_position = so.cursor_position.saturating_sub(1);
586 so.string
587 .remove(<u16 as Into<usize>>::into(so.cursor_position));
588 populate_word_index(so);
589 so.cursor_position = so.cursor_position.saturating_add(1);
590 refresh_display(out, so)?;
592 term::move_cursor(out, so.cursor_position, so.rows, false)?;
593 out.flush()?;
594 }
595 Event::Key(KeyEvent {
596 code: KeyCode::Enter,
597 modifiers: KeyModifiers::NONE,
598 ..
599 }) => {
600 so.input_status = InputStatus::Confirmed;
601 }
602 Event::Key(KeyEvent {
603 code: KeyCode::Left,
604 modifiers: KeyModifiers::NONE,
605 ..
606 }) => {
607 if so.cursor_position == FIRST_AVAILABLE_COLUMN {
608 return Ok(());
609 }
610 so.cursor_position = so.cursor_position.saturating_sub(1);
611 term::move_cursor(out, so.cursor_position, so.rows, true)?;
612 }
613 Event::Key(KeyEvent {
614 code: KeyCode::Left,
615 modifiers: KeyModifiers::CONTROL,
616 ..
617 }) => {
618 so.cursor_position = *so
622 .word_index
623 .iter()
624 .rfind(|c| c < &&so.cursor_position)
625 .unwrap_or(&FIRST_AVAILABLE_COLUMN);
626 term::move_cursor(out, so.cursor_position, so.rows, true)?;
627 }
628 Event::Key(KeyEvent {
629 code: KeyCode::Right,
630 modifiers: KeyModifiers::NONE,
631 ..
632 }) => {
633 if so.cursor_position >= last_available_column {
634 return Ok(());
635 }
636 so.cursor_position = so.cursor_position.saturating_add(1);
637 term::move_cursor(out, so.cursor_position, so.rows, true)?;
638 }
639 Event::Key(KeyEvent {
640 code: KeyCode::Right,
641 modifiers: KeyModifiers::CONTROL,
642 ..
643 }) => {
644 so.cursor_position = *so
648 .word_index
649 .iter()
650 .find(|c| c > &&so.cursor_position)
651 .unwrap_or(&last_available_column);
652 term::move_cursor(out, so.cursor_position, so.rows, true)?;
653 }
654 Event::Key(KeyEvent {
655 code: KeyCode::Home,
656 modifiers: KeyModifiers::NONE,
657 ..
658 }) => {
659 so.cursor_position = 1;
660 term::move_cursor(out, 1, so.rows, true)?;
661 }
662 Event::Key(KeyEvent {
663 code: KeyCode::End,
664 modifiers: KeyModifiers::NONE,
665 ..
666 }) => {
667 so.cursor_position = so.string.len().saturating_add(1).try_into().unwrap();
668 term::move_cursor(out, so.cursor_position, so.rows, true)?;
669 }
670 Event::Key(KeyEvent {
671 code: KeyCode::Char('i'),
672 modifiers: KeyModifiers::ALT,
673 ..
674 }) => {
675 so.smart_case = !so.smart_case;
676 populate_word_index(so);
677 refresh_display(out, so)?;
678 term::move_cursor(out, so.cursor_position, so.rows, false)?;
679 out.flush()?;
680 }
681 Event::Key(KeyEvent {
682 code: KeyCode::Char(c),
683 modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
684 ..
685 }) => {
686 so.string
689 .insert(so.cursor_position.saturating_sub(1).into(), *c);
690 populate_word_index(so);
691 refresh_display(out, so)?;
692 so.cursor_position = so.cursor_position.saturating_add(1);
693 term::move_cursor(out, so.cursor_position, so.rows, false)?;
694 out.flush()?;
695 }
696 _ => return Ok(()),
697 }
698 Ok(())
699}
700
701#[cfg(feature = "search")]
709pub(crate) fn fetch_input(
710 out: &mut impl std::io::Write,
711 ps: &PagerState,
712) -> Result<FetchInputResult, MinusError> {
713 let search_char = if ps.search_state.search_mode == SearchMode::Forward {
715 '/'
716 } else {
717 '?'
718 };
719
720 term::move_cursor(out, 0, ps.rows.try_into().unwrap(), false)?;
726 write!(
727 out,
728 "{}{}{}",
729 Clear(ClearType::CurrentLine),
730 search_char,
731 cursor::Show
732 )?;
733 out.flush()?;
734
735 let mut search_opts = SearchOpts::from(ps);
736
737 loop {
739 if event::poll(Duration::from_millis(100)).map_err(|e| MinusError::HandleEvent(e.into()))? {
740 let ev = event::read().map_err(|e| MinusError::HandleEvent(e.into()))?;
741 search_opts.ev = Some(ev);
742 handle_key_press(
743 out,
744 &mut search_opts,
745 &ps.search_state.incremental_search_condition,
746 )?;
747 search_opts.ev = None;
748 }
749 if search_opts.input_status.done() {
750 break;
751 }
752 }
753 term::move_cursor(out, 0, ps.rows.try_into().unwrap(), false)?;
755 write!(out, "{}{}", Clear(ClearType::CurrentLine), cursor::Hide)?;
756 out.flush()?;
757
758 let fetch_input_result = match search_opts.input_status {
759 InputStatus::Active => unreachable!(),
760 InputStatus::Cancelled => FetchInputResult::new_empty(),
761 InputStatus::Confirmed => FetchInputResult {
764 string: search_opts.string,
765 compiled_regex: search_opts.compiled_regex,
766 smart_case: search_opts.smart_case,
767 },
768 };
769 Ok(fetch_input_result)
770}
771
772pub(crate) fn highlight_matches_args<'a, 'b>(
773 line: &'a str,
774 query: &'b Regex,
775 accurate: bool,
776) -> HighlightMatchesArgs<'a, 'b> {
777 let stripped_str = ANSI_REGEX.replace_all(line, "");
778 let is_match = query.is_match(&stripped_str);
779 HighlightMatchesArgs {
780 line,
781 query,
782 accurate,
783 is_match,
784 }
785}
786
787fn highlight_line_matches_ansi(line: &str, query: ®ex::Regex, accurate: bool) -> String {
788 let stripped_str = ANSI_REGEX.replace_all(line, "");
789
790 if !query.is_match(&stripped_str) {
792 return line.to_string();
793 }
794
795 let mut sum_width = 0;
798
799 let escapes = ANSI_REGEX
804 .find_iter(line)
805 .map(|escape| {
806 let start = escape.start();
807 let as_str = escape.as_str();
808 let ret = (start - sum_width, as_str);
809 sum_width += as_str.len();
810 ret
811 })
812 .collect::<Vec<_>>();
813
814 let matches = query
817 .find_iter(&stripped_str)
818 .flat_map(|c| [c.start(), c.end()])
819 .collect::<Vec<_>>();
820
821 let mut inverted = query
824 .replace_all(&stripped_str, |caps: ®ex::Captures| {
825 format!("{}{}{}", *INVERT, &caps[0], *NORMAL)
826 })
827 .to_string();
828
829 let mut inserted_escs_len = 0;
832 for esc in escapes {
833 let match_count = matches.iter().take_while(|m| **m <= esc.0).count();
834 let num_invert = match_count / 2;
839 let num_normal = match_count - num_invert;
840
841 let mut pos = if !accurate && match_count % 2 == 1 {
846 matches.get(match_count).unwrap()
848 + NORMAL.len()
849 + inserted_escs_len
850 + (num_invert * INVERT.len())
851 + (num_normal * NORMAL.len())
852 } else {
853 esc.0 + inserted_escs_len + (num_invert * INVERT.len()) + (num_normal * NORMAL.len())
854 };
855
856 if match_count % 2 == 1 {
857 pos = pos.saturating_sub(1);
858 }
859
860 inverted.insert_str(pos, esc.1);
862
863 inserted_escs_len += esc.1.len();
865 }
866
867 inverted
868}
869
870#[cfg_attr(not(test), allow(dead_code))]
875pub(crate) fn highlight_line_matches(
876 line: &str,
877 query: ®ex::Regex,
878 accurate: bool,
879) -> (String, bool) {
880 let highlighted = highlight_matches_args(line, query, accurate);
881 (highlighted.to_string(), highlighted.is_match)
882}
883
884pub(crate) struct HighlightMatchesArgs<'a, 'b> {
885 line: &'a str,
886 query: &'b Regex,
887 accurate: bool,
888 is_match: bool,
889}
890
891impl fmt::Display for HighlightMatchesArgs<'_, '_> {
892 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
893 if !self.is_match {
894 return f.write_str(self.line);
895 }
896
897 if !ANSI_REGEX.is_match(self.line) {
898 let mut last = 0;
899 for matched in self.query.find_iter(self.line) {
900 f.write_str(&self.line[last..matched.start()])?;
901 write!(f, "{}{}{}", *INVERT, matched.as_str(), *NORMAL)?;
902 last = matched.end();
903 }
904 return f.write_str(&self.line[last..]);
905 }
906
907 f.write_str(&highlight_line_matches_ansi(
908 self.line,
909 self.query,
910 self.accurate,
911 ))
912 }
913}
914
915#[must_use]
931pub(crate) fn next_nth_match(
932 search_idx: &BTreeSet<usize>,
933 upper_mark: usize,
934 jump: usize,
935) -> Option<usize> {
936 if search_idx.is_empty() {
937 return None;
938 }
939
940 let nearest_idx = search_idx.iter().position(|i| {
943 if jump == 0 {
944 *i >= upper_mark
945 } else {
946 *i > upper_mark
947 }
948 });
949
950 let start_idx = nearest_idx.unwrap_or(0);
951 let position_of_next_match = if jump == 0 {
952 start_idx
953 } else {
954 start_idx.saturating_add(jump - 1) % search_idx.len()
955 };
956
957 Some(position_of_next_match)
958}
959
960#[cfg(test)]
961mod tests {
962 mod input_handling {
963 use crate::{
964 SearchMode,
965 search::{InputStatus, SearchOpts, handle_key_press},
966 };
967 use crossterm::{
968 cursor::MoveTo,
969 event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers},
970 terminal::{Clear, ClearType},
971 };
972 use std::{convert::TryInto, io::Write};
973
974 fn new_search_opts(sm: SearchMode) -> SearchOpts<'static> {
975 let search_char = match sm {
976 SearchMode::Forward => '/',
977 SearchMode::Reverse => '?',
978 SearchMode::Unknown => unreachable!(),
979 };
980
981 SearchOpts {
982 ev: None,
983 string: String::with_capacity(200),
984 input_status: InputStatus::Active,
985 cursor_position: 1,
986 word_index: Vec::with_capacity(200),
987 search_char,
988 rows: 25,
989 cols: 100,
990 incremental_search_options: None,
991 smart_case: false,
992 compiled_regex: None,
993 search_mode: sm,
994 }
995 }
996
997 const fn make_event_from_keycode(kc: KeyCode) -> Event {
998 Event::Key(KeyEvent {
999 code: kc,
1000 kind: KeyEventKind::Press,
1001 modifiers: KeyModifiers::NONE,
1002 state: KeyEventState::NONE,
1003 })
1004 }
1005
1006 fn pretest_setup_forward_search() -> (SearchOpts<'static>, Vec<u8>, u16, &'static str) {
1007 const QUERY_STRING: &str = "this is@complex-text_search?query"; #[allow(clippy::cast_possible_truncation)]
1009 let last_movable_column: u16 = (QUERY_STRING.len() as u16) + 1; let mut search_opts = new_search_opts(SearchMode::Forward);
1012 let mut out = Vec::with_capacity(1500);
1013
1014 for c in QUERY_STRING.chars() {
1015 search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
1016 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1017 }
1018 assert_eq!(search_opts.cursor_position, last_movable_column);
1019 (search_opts, out, last_movable_column, QUERY_STRING)
1020 }
1021
1022 #[test]
1023 fn input_sequential_text() {
1024 let mut search_opts = new_search_opts(SearchMode::Forward);
1025 let mut out = Vec::with_capacity(1500);
1026 for (i, c) in "text search matches".chars().enumerate() {
1027 search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
1028 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1029 assert_eq!(search_opts.input_status, InputStatus::Active);
1030 assert_eq!(search_opts.cursor_position as usize, i + 2);
1031 }
1032 search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter));
1033 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1034 assert_eq!(search_opts.word_index, vec![1, 5, 6, 12, 13]);
1035 assert_eq!(&search_opts.string, "text search matches");
1036 assert_eq!(search_opts.input_status, InputStatus::Confirmed);
1037 }
1038
1039 #[test]
1040 fn input_complex_sequential_text() {
1041 let mut search_opts = new_search_opts(SearchMode::Forward);
1042 let mut out = Vec::with_capacity(1500);
1043 for (i, c) in "this is@complex-text_search?query".chars().enumerate() {
1044 search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
1045 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1046 assert_eq!(search_opts.input_status, InputStatus::Active);
1047 assert_eq!(search_opts.cursor_position as usize, i + 2);
1048 }
1049 search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter));
1050 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1051 assert_eq!(search_opts.word_index, vec![1, 5, 6, 8, 9, 16, 17, 28, 29]);
1052 assert_eq!(&search_opts.string, "this is@complex-text_search?query");
1053 assert_eq!(search_opts.input_status, InputStatus::Confirmed);
1054 }
1055
1056 #[test]
1057 fn input_uppercase_and_shifted_text() {
1058 let mut search_opts = new_search_opts(SearchMode::Forward);
1059 let mut out = Vec::with_capacity(1500);
1060 for (i, c) in "Hello World".chars().enumerate() {
1061 let modifiers = if c.is_uppercase() {
1062 KeyModifiers::SHIFT
1063 } else {
1064 KeyModifiers::NONE
1065 };
1066 search_opts.ev = Some(Event::Key(KeyEvent {
1067 code: KeyCode::Char(c),
1068 kind: KeyEventKind::Press,
1069 modifiers,
1070 state: KeyEventState::NONE,
1071 }));
1072 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1073 assert_eq!(search_opts.input_status, InputStatus::Active);
1074 assert_eq!(search_opts.cursor_position as usize, i + 2);
1075 }
1076 search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter));
1077 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1078 assert_eq!(&search_opts.string, "Hello World");
1079 assert_eq!(search_opts.input_status, InputStatus::Confirmed);
1080 }
1081
1082 #[test]
1083 fn home_end_keys() {
1084 let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search();
1086
1087 search_opts.ev = Some(make_event_from_keycode(KeyCode::Home));
1088 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1089 assert_eq!(search_opts.cursor_position as usize, 1);
1090
1091 search_opts.ev = Some(make_event_from_keycode(KeyCode::End));
1092 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1093 assert_eq!(search_opts.cursor_position, last_movable_column);
1094 }
1095
1096 #[test]
1097 fn basic_left_arrow_movement() {
1098 const FIRST_MOVABLE_COLUMN: u16 = 1;
1099 let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search();
1100 let query_string_length = last_movable_column - 1;
1101
1102 for i in (FIRST_MOVABLE_COLUMN..=query_string_length).rev() {
1108 search_opts.ev = Some(make_event_from_keycode(KeyCode::Left));
1109 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1110 assert_eq!(search_opts.cursor_position, i);
1111 }
1112 search_opts.ev = Some(make_event_from_keycode(KeyCode::Left));
1114 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1115 assert_eq!(search_opts.cursor_position, FIRST_MOVABLE_COLUMN);
1116 }
1117
1118 #[test]
1119 fn basic_right_arrow_movement() {
1120 let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search();
1122 search_opts.ev = Some(make_event_from_keycode(KeyCode::Home));
1124 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1125
1126 for i in 2..=last_movable_column {
1130 search_opts.ev = Some(make_event_from_keycode(KeyCode::Right));
1131 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1132 assert_eq!(search_opts.cursor_position, i);
1133 }
1134 search_opts.ev = Some(make_event_from_keycode(KeyCode::Right));
1136 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1137 assert_eq!(search_opts.cursor_position, last_movable_column);
1138 }
1139
1140 #[test]
1141 fn right_jump_by_word() {
1142 const JUMP_COLUMNS: [u16; 10] = [1, 5, 6, 8, 9, 16, 17, 28, 29, LAST_MOVABLE_COLUMN];
1143 let (mut search_opts, mut out, _last_movable_column, _) =
1145 pretest_setup_forward_search();
1146 #[allow(clippy::items_after_statements)]
1148 const LAST_MOVABLE_COLUMN: u16 = 34;
1149
1150 search_opts.ev = Some(make_event_from_keycode(KeyCode::Home));
1152 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1153
1154 let ev = Event::Key(KeyEvent {
1155 code: KeyCode::Right,
1156 kind: KeyEventKind::Press,
1157 modifiers: KeyModifiers::CONTROL,
1158 state: KeyEventState::NONE,
1159 });
1160
1161 for i in &JUMP_COLUMNS[1..] {
1163 search_opts.ev = Some(ev.clone());
1164 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1165 assert_eq!(search_opts.cursor_position, *i);
1166 }
1167 search_opts.ev = Some(ev);
1170 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1171 assert_eq!(search_opts.cursor_position, LAST_MOVABLE_COLUMN);
1172 }
1173
1174 #[test]
1175 fn left_jump_by_word() {
1176 const JUMP_COLUMNS: [u16; 10] = [1, 5, 6, 8, 9, 16, 17, 28, 29, LAST_MOVABLE_COLUMN];
1177 let (mut search_opts, mut out, _last_movable_column, _) =
1179 pretest_setup_forward_search();
1180 #[allow(clippy::items_after_statements)]
1182 const LAST_MOVABLE_COLUMN: u16 = 34;
1183
1184 let ev = Event::Key(KeyEvent {
1186 code: KeyCode::Left,
1187 kind: KeyEventKind::Press,
1188 modifiers: KeyModifiers::CONTROL,
1189 state: KeyEventState::NONE,
1190 });
1191
1192 for i in (JUMP_COLUMNS[..(JUMP_COLUMNS.len() - 1)]).iter().rev() {
1194 search_opts.ev = Some(ev.clone());
1195 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1196 assert_eq!(search_opts.cursor_position, *i);
1197 }
1198 search_opts.ev = Some(ev);
1200 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1201 assert_eq!(search_opts.cursor_position, JUMP_COLUMNS[0]);
1202 }
1203
1204 #[test]
1205 fn esc_key() {
1206 let (mut search_opts, mut out, _, _) = pretest_setup_forward_search();
1207
1208 search_opts.ev = Some(make_event_from_keycode(KeyCode::Esc));
1209 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1210 assert_eq!(search_opts.input_status, InputStatus::Cancelled);
1211 }
1212
1213 #[test]
1214 fn forward_sequential_text_input_screen_data() {
1215 let (search_opts, out, _last_movable_column, query_string) =
1216 pretest_setup_forward_search();
1217
1218 let mut result_out = Vec::with_capacity(1500);
1219
1220 let mut string = String::with_capacity(query_string.len());
1222 let mut cursor_position: u16 = 1;
1223 for c in query_string.chars() {
1224 string.push(c);
1225 cursor_position = cursor_position.saturating_add(1);
1226 write!(
1227 result_out,
1228 "{move_to_prompt}\r{clear_line}/{string}{move_to_position}",
1229 move_to_prompt = MoveTo(0, search_opts.rows),
1230 clear_line = Clear(ClearType::CurrentLine),
1231 move_to_position = MoveTo(cursor_position, search_opts.rows),
1232 )
1233 .unwrap();
1234 }
1235 assert_eq!(out, result_out);
1236 }
1237
1238 #[test]
1239 fn backward_sequential_text_input_screen_data() {
1240 const QUERY_STRING: &str = "this is@complex-text_search?query"; #[allow(clippy::cast_possible_truncation)]
1242 const LAST_MOVABLE_COLUMN: u16 = (QUERY_STRING.len() as u16) + 1; let mut search_opts = new_search_opts(SearchMode::Reverse);
1245 let mut out = Vec::with_capacity(1500);
1246
1247 for c in QUERY_STRING.chars() {
1248 search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
1249 handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1250 }
1251 assert_eq!(search_opts.cursor_position, LAST_MOVABLE_COLUMN);
1252
1253 let mut result_out = Vec::with_capacity(1500);
1254
1255 let mut string = String::with_capacity(QUERY_STRING.len());
1257 let mut cursor_position: u16 = 1;
1258 for c in QUERY_STRING.chars() {
1259 string.push(c);
1260 cursor_position = cursor_position.saturating_add(1);
1261 write!(
1262 result_out,
1263 "{move_to_prompt}\r{clear_line}?{string}{move_to_position}",
1264 move_to_prompt = MoveTo(0, search_opts.rows),
1265 clear_line = Clear(ClearType::CurrentLine),
1266 move_to_position = MoveTo(cursor_position, search_opts.rows),
1267 )
1268 .unwrap();
1269 }
1270 assert_eq!(out, result_out);
1271 }
1272 }
1273
1274 #[test]
1275 fn test_compile_regex_smart_case() {
1276 let re = super::compile_regex("hello", true).unwrap();
1278 assert!(re.is_match("hello"));
1279 assert!(re.is_match("HELLO"));
1280 assert!(re.is_match("Hello"));
1281
1282 let re = super::compile_regex("Hello", true).unwrap();
1284 assert!(re.is_match("Hello"));
1285 assert!(!re.is_match("hello"));
1286 assert!(!re.is_match("HELLO"));
1287
1288 let re = super::compile_regex("hello", false).unwrap();
1290 assert!(re.is_match("hello"));
1291 assert!(!re.is_match("HELLO"));
1292 assert!(!re.is_match("Hello"));
1293 }
1294
1295 #[test]
1296 fn test_next_match() {
1297 let search_idx = std::collections::BTreeSet::from([2, 10, 15, 17, 50]);
1299 let mut upper_mark = 0;
1300 let mut search_mark;
1301 for (i, v) in search_idx.iter().enumerate() {
1302 search_mark = super::next_nth_match(&search_idx, upper_mark, 1);
1303 assert_eq!(search_mark, Some(i));
1304 let next_upper_mark = *search_idx.iter().nth(search_mark.unwrap()).unwrap();
1305 assert_eq!(next_upper_mark, *v);
1306 upper_mark = next_upper_mark;
1307 }
1308 }
1309
1310 #[allow(clippy::trivial_regex)]
1311 mod highlighting {
1312 use std::collections::BTreeSet;
1313
1314 use crate::PagerState;
1315 use crate::search::{INVERT, NORMAL, highlight_line_matches, next_nth_match};
1316 use crossterm::style::Attribute;
1317 use regex::Regex;
1318
1319 const ESC: &str = "\x1b[34m";
1321 const NONE: &str = "\x1b[0m";
1322
1323 mod consistent {
1324 use super::*;
1325
1326 #[test]
1327 fn test_highlight_matches() {
1328 let line = "Integer placerat tristique nisl. placerat non mollis, magna orci dolor, placerat at vulputate neque nulla lacinia eros.".to_string();
1329 let pat = Regex::new(r"\W\w+t\W").unwrap();
1330 let result = format!(
1331 "Integer{inverse} placerat {noinverse}tristique nisl.\
1332{inverse} placerat {noinverse}non mollis, magna orci dolor,\
1333{inverse} placerat {noinverse}at vulputate neque nulla lacinia \
1334eros.",
1335 inverse = Attribute::Reverse,
1336 noinverse = Attribute::NoReverse
1337 );
1338
1339 assert_eq!(highlight_line_matches(&line, &pat, false).0, result);
1340 }
1341
1342 #[test]
1343 fn no_match() {
1344 let orig = "no match";
1345 let res = highlight_line_matches(orig, &Regex::new("test").unwrap(), false);
1346 assert_eq!(res.0, orig.to_string());
1347 }
1348
1349 #[test]
1350 fn single_match_no_esc() {
1351 let res =
1352 highlight_line_matches("this is a test", &Regex::new(" a ").unwrap(), false);
1353 assert_eq!(res.0, format!("this is{} a {}test", *INVERT, *NORMAL));
1354 }
1355
1356 #[test]
1357 fn multi_match_no_esc() {
1358 let res = highlight_line_matches(
1359 "test another test",
1360 &Regex::new("test").unwrap(),
1361 false,
1362 );
1363 assert_eq!(
1364 res.0,
1365 format!("{i}test{n} another {i}test{n}", i = *INVERT, n = *NORMAL)
1366 );
1367 }
1368
1369 #[test]
1372 fn esc_pair_outside_match() {
1373 let res = highlight_line_matches(
1374 &format!("{ESC}color{NONE} and test"),
1375 &Regex::new("test").unwrap(),
1376 false,
1377 );
1378 assert_eq!(
1379 res.0,
1380 format!("{}color{} and {}test{}", ESC, NONE, *INVERT, *NORMAL)
1381 );
1382 }
1383
1384 #[test]
1385 fn esc_pair_end_in_match() {
1386 let orig = format!("this {ESC}is a te{NONE}st");
1387 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
1388 assert_eq!(
1389 res.0,
1390 format!("this {}is a {}test{}{}", ESC, *INVERT, *NORMAL, NONE)
1391 );
1392 }
1393
1394 #[test]
1395 fn esc_pair_start_in_match() {
1396 let orig = format!("this is a te{ESC}st again{NONE}");
1397 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
1398 assert_eq!(
1399 res.0,
1400 format!("this is a {}test{}{ESC} again{}", *INVERT, *NORMAL, NONE)
1401 );
1402 }
1403
1404 #[test]
1405 fn esc_pair_around_match() {
1406 let orig = format!("this is {ESC}a test again{NONE}");
1407 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
1408 assert_eq!(
1409 res.0,
1410 format!("this is {}a {}test{} again{}", ESC, *INVERT, *NORMAL, NONE)
1411 );
1412 }
1413
1414 #[test]
1415 fn esc_pair_within_match() {
1416 let orig = format!("this is a t{ESC}es{NONE}t again");
1417 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
1418 assert_eq!(
1419 res.0,
1420 format!("this is a {}test{}{ESC}{NONE} again", *INVERT, *NORMAL)
1421 );
1422 }
1423
1424 #[test]
1425 fn multi_escape_match() {
1426 let orig = format!("this {ESC}is a te{NONE}st again {ESC}yeah{NONE} test");
1427 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
1428 assert_eq!(
1429 res.0,
1430 format!(
1431 "this {e}is a {i}test{n}{nn} again {e}yeah{nn} {i}test{n}",
1432 e = ESC,
1433 i = *INVERT,
1434 n = *NORMAL,
1435 nn = NONE
1436 )
1437 );
1438 }
1439 }
1440 mod accurate {
1441 use super::*;
1442 #[test]
1443 fn correct_ascii_sequence_placement() {
1444 let orig = format!(
1445 "{ESC}test{NONE} this {ESC}is a te{NONE}st again {ESC}yeah{NONE} test",
1446 );
1447
1448 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
1449 assert_eq!(
1450 res.0,
1451 format!(
1452 "{i}{e}test{n}{nn} this {e}is a {i}te{NONE}st{n} again {e}yeah{nn} {i}test{n}",
1453 e = ESC,
1454 i = *INVERT,
1455 n = *NORMAL,
1456 nn = NONE
1457 )
1458 );
1459 }
1460
1461 #[test]
1463 fn esc_pair_outside_match() {
1464 let res = highlight_line_matches(
1465 &format!("{ESC}color{NONE} and test"),
1466 &Regex::new("test").unwrap(),
1467 true,
1468 );
1469 assert_eq!(
1470 res.0,
1471 format!("{}color{} and {}test{}", ESC, NONE, *INVERT, *NORMAL)
1472 );
1473 }
1474
1475 #[test]
1476 fn esc_pair_end_in_match() {
1477 let orig = format!("this {ESC}is a te{NONE}st");
1478 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
1479 assert_eq!(
1480 res.0,
1481 format!("this {ESC}is a {}te{NONE}st{}", *INVERT, *NORMAL)
1482 );
1483 }
1484
1485 #[test]
1486 fn esc_pair_start_in_match() {
1487 let orig = format!("this is a te{ESC}st again{NONE}");
1488 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
1489 assert_eq!(
1490 res.0,
1491 format!("this is a {}te{ESC}st{} again{NONE}", *INVERT, *NORMAL)
1492 );
1493 }
1494
1495 #[test]
1496 fn esc_pair_around_match() {
1497 let orig = format!("this is {ESC}a test again{NONE}");
1498 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
1499 assert_eq!(
1500 res.0,
1501 format!("this is {ESC}a {}test{} again{NONE}", *INVERT, *NORMAL)
1502 );
1503 }
1504
1505 #[test]
1506 fn esc_pair_within_match() {
1507 let orig = format!("this is a t{ESC}es{NONE}t again");
1508 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
1509 assert_eq!(
1510 res.0,
1511 format!("this is a {}t{ESC}es{NONE}t{} again", *INVERT, *NORMAL)
1512 );
1513 }
1514
1515 #[test]
1516 fn multi_escape_match() {
1517 let orig = format!("this {ESC}is a te{NONE}st again {ESC}yeah{NONE} test");
1518 let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
1519 assert_eq!(
1520 res.0,
1521 format!(
1522 "this {e}is a {i}te{nn}st{n} again {e}yeah{nn} {i}test{n}",
1523 e = ESC,
1524 i = *INVERT,
1525 n = *NORMAL,
1526 nn = NONE
1527 )
1528 );
1529 }
1530 }
1531 }
1532}