Skip to main content

minus/
search.rs

1#![cfg_attr(docsrs, doc(cfg(feature = "search")))]
2//! Text searching functionality
3//!
4//! Text searching inside minus is quite advanced than other terminal pagers. It is highly
5//! inspired by modern text editors and hence provides features like:-
6//! - [Keybindings](../index.html#key-bindings-available-at-search-prompt) similar to modern text editors
7//! - Incremental search
8//! - Full regex support for writing advanced search queries
9//!   and more...
10//!
11//! # Incremental Search
12//! minus supports incrementally searching the text. This means that you can view the search
13//! matches inside the text match as soon as you start typing the query.
14//!
15//! It is also significant because minus caches a lot of results from each incremental search run
16//! and then reuses those results when the search query is confirmed by pressing `Enter`. This
17//! approach eliminates the need to re run the search of text after confirming the query.
18//!
19//! Running Incremental search can be controlled by a function. The function should take
20//! reference to [`SearchOpts`] as the only argument and return a bool as output. This way we can impose a
21//! condition so that incremental search does not get really resource intensive for really vague queries
22//! This also allows applications can control whether they want incremental search to run.
23//! By default minus uses a default condition where incremental search runs only when length of search
24//! query is greater than 1 and number of screen lines (lines obtained after taking care of wrapping,
25//! mapped to a single row on the terminal) is greater than 5000.
26//!
27//! Applications can override this condition with the help of
28//! [`Pager::set_incremental_search_condition`](crate::pager::Pager::set_incremental_search_condition) function.
29//!
30//! Here is a an example to demonstrate on its usage. Here we set the condition to run incremental
31//! search only when the length of the search query is greater than 1.
32//! ```
33//! use minus::{Pager, search::SearchOpts};
34//!
35//! let pager = Pager::new();
36//! pager.set_incremental_search_condition(Box::new(|so: &SearchOpts| so.string.len() > 1)).unwrap();
37//! ```
38//! To completely disable incremental search, set the condition to false
39//! ```
40//! use minus::{Pager, search::SearchOpts};
41//!
42//! let pager = Pager::new();
43//! pager.set_incremental_search_condition(Box::new(|_| false)).unwrap();
44//! ```
45//! Similarly to always run incremental search, set the condition to true
46//! ```
47//! use minus::{Pager, search::SearchOpts};
48//!
49//! let pager = Pager::new();
50//! pager.set_incremental_search_condition(Box::new(|_| true)).unwrap();
51//! ```
52
53#![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)]
91/// Defines modes in which the search can run
92pub enum SearchMode {
93    /// Find matches from or after the current page
94    Forward,
95    /// Find matches before the current page
96    Reverse,
97    /// No search active
98    #[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/// Options controlling the behaviour of search overall
109///
110/// Although it isn't much important for most use cases but it alongside [`IncrementalSearchOpts`] are the key components
111/// when applications want to customize the incremental seaech condition.
112///
113/// Most of the fields have self-explanatory names so it should be very easy to get started using
114/// this
115#[allow(clippy::module_name_repetitions)]
116pub struct SearchOpts<'a> {
117    /// A [`crossterm Event`](Event) on which to respond
118    pub ev: Option<Event>,
119    /// Current string query
120    pub string: String,
121    /// Status of the input prompt. See [`InputStatus`]
122    pub input_status: InputStatus,
123    /// Specifies the terminal column number that the cursor on at the prompt site.
124    /// It can range between 1 and `string.len() + 1`
125    pub cursor_position: u16,
126    /// Direction of search. See [`SearchMode`].
127    pub search_mode: SearchMode,
128    /// Column numbers where each new word start
129    pub word_index: Vec<u16>,
130    /// Search character, either `/` or `?` depending on [`SearchMode`]
131    pub search_char: char,
132    /// Number of rows available in the terminal
133    pub rows: u16,
134    /// Number of cols available in the terminal
135    pub cols: u16,
136    /// Options specifically controlling incremental search
137    pub incremental_search_options: Option<IncrementalSearchOpts<'a>>,
138    /// Whether smart case search is enabled
139    pub smart_case: bool,
140    compiled_regex: Option<Regex>,
141}
142
143/// Options to control incremental search
144pub struct IncrementalSearchOpts<'a> {
145    /// Current status of line numbering
146    pub line_numbers: LineNumbers,
147    /// Value of [`PagerState::upper_mark`] before starting of search prompt
148    pub initial_upper_mark: usize,
149    /// Reference to [`PagerState::screen`]
150    pub screen: &'a Screen,
151    /// Cached map from logical lines to formatted rows.
152    pub lines_to_row_map: &'a LinesRowMap,
153    /// Value of [`PagerState::upper_mark`] before starting of search prompt
154    pub initial_left_mark: usize,
155    /// Value of [`PagerState::cols`]
156    cols: usize,
157    /// Value of [`PagerState::rows`] - 1 and 0 if rows is 0.
158    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/// Status of the search prompt
212#[derive(Debug, Eq, PartialEq, Clone)]
213pub enum InputStatus {
214    /// Closed due to confirmation of search query using `Enter`
215    Confirmed,
216    /// Closed due to abortion using `Esc`
217    Cancelled,
218    /// Search prompt is open
219    Active,
220}
221
222impl InputStatus {
223    /// Returns true if the input prompt is closed either by confirming the query or by cancelling
224    /// he search
225    #[must_use]
226    pub const fn done(&self) -> bool {
227        matches!(self, Self::Cancelled | Self::Confirmed)
228    }
229}
230
231/// Return type of [`fetch_input`]
232pub(crate) struct FetchInputResult {
233    /// Original search query
234    pub(crate) string: String,
235    /// Cached pre-compiled [`Regex`] if available
236    pub(crate) compiled_regex: Option<Regex>,
237    /// Smart case setting at the time of search confirmation
238    pub(crate) smart_case: bool,
239}
240
241impl FetchInputResult {
242    /// Create an empty `FetchInputResult` with string set to empty string and
243    /// `incremental_search_cache` and `compiled_regex` set to `None`.
244    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    // Skip all lines that don't have any match
279    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    // visible_lines places the first search march as its first element. However if the match is
366    // near the EOF, it might not fill up completely and show blank lines on the display.
367    // We fix this by filling visible_lines by as many rows such that a pageful of data can be
368    // displayed.
369    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
420/// Runs the incremental search
421///
422/// It will return if `Ok(SomeIncrementalSearchCache)` if there was a successful run of incremental
423/// search otherwise it will return `Ok(None)`.
424///
425/// # Errors
426/// This function will returns a `Err(MinusError)` if any operation on the terminal failed to
427/// execute.
428fn 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    // Check if we can continue forward with incremental search
446    let should_proceed = so.compiled_regex.is_some() && incremental_search_condition(so);
447
448    // **Screen resetting**:
449    // This is an important bit when running incremental search.It reset the terminal screen to
450    // display the lines from the same location and in the same way as before the search even
451    // started. Basically print it exactly how it looked before pressing `/` or `?`,
452    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 the query prior to the current one had a successful incremental search run and now the
468    // current query isn't a valid regex or the incremental search condition has returned false
469    // then
470    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    // Draw the incrementally searched lines from upper mark
483    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/// Respond to keyboard events
499///
500/// This souuld be called exactly once for each event by [`fetch_input`]
501#[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    // Bounds between which our cursor can move
512    const FIRST_AVAILABLE_COLUMN: u16 = 1;
513    let last_available_column: u16 = so.string.len().saturating_add(1).try_into().unwrap();
514
515    // If no event is present, abort
516    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        // Cache the compiled regex if the regex is valid
529        so.compiled_regex = compile_regex(&so.string, so.smart_case);
530
531        run_incremental_search(out, so, incremental_search_condition)?;
532
533        // Update prompt
534        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        // If Esc is pressed, cancel the search and also make sure that the search query is
547        // ")cleared
548        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            // On backspace, remove the last character just before the cursor from the so.string
562            // But if we are at very first character, do nothing.
563            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            // Update the line
571            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            // On delete, remove the character under the cursor from the so.string
581            // But if we are at the column right after the last character, do nothing.
582            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            // Update the line
591            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            // Find the column number where a word starts which is exactly before the current
619            // cursor position
620            // If we can't find any such column, jump to the very first available column
621            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            // Find the column number where a word starts which is exactly after the current
645            // cursor position
646            // If we can't find any such column, jump to the very last available column
647            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            // For any character key, without a modifier (or with Shift), insert it into so.string before
687            // current cursor position and update the line
688            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/// Fetch the search query
702///
703/// The function will change the prompt to `/` for Forward search or `?` for Reverse search.
704/// Next it fetches and handles all events from the terminal screen until [`SearchOpts::input_status`] isn't
705/// set to either [`InputStatus::Cancelled`] or [`InputStatus::Confirmed`] by pressing `Esc` or
706/// `Enter` respectively.
707/// Finally we return
708#[cfg(feature = "search")]
709pub(crate) fn fetch_input(
710    out: &mut impl std::io::Write,
711    ps: &PagerState,
712) -> Result<FetchInputResult, MinusError> {
713    // Set the search character to show at column 0
714    let search_char = if ps.search_state.search_mode == SearchMode::Forward {
715        '/'
716    } else {
717        '?'
718    };
719
720    // Initial setup
721    // - Place the cursor at the beginning of prompt line
722    // - Clear the prompt
723    // - Write the search character and
724    // - Show the cursor
725    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    // Fetch events from the terminal and handle them
738    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    // Teardown: almost opposite of setup
754    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        // When the query is confirmed, return the actual query along with everything that is valid
762        // in the cache
763        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: &regex::Regex, accurate: bool) -> String {
788    let stripped_str = ANSI_REGEX.replace_all(line, "");
789
790    // if it doesn't match, don't even try. Just return.
791    if !query.is_match(&stripped_str) {
792        return line.to_string();
793    }
794
795    // sum_width is used to calculate the total width of the ansi escapes
796    // up to the point in the original string where it is being used
797    let mut sum_width = 0;
798
799    // find all ansi escapes in the original string, and map them
800    // to a Vec<(usize, &str)> where
801    //   .0 == the start index in the STRIPPED string
802    //   .1 == the escape sequence itself
803    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    // The matches of the term you're looking for, so that you can easily determine where
815    // the invert attributes will be placed
816    let matches = query
817        .find_iter(&stripped_str)
818        .flat_map(|c| [c.start(), c.end()])
819        .collect::<Vec<_>>();
820
821    // Highlight all the instances of the search term in the stripped string
822    // by inverting their background/foreground colors
823    let mut inverted = query
824        .replace_all(&stripped_str, |caps: &regex::Captures| {
825            format!("{}{}{}", *INVERT, &caps[0], *NORMAL)
826        })
827        .to_string();
828
829    // inserted_escs_len == the total length of the ascii escapes which have been re-inserted
830    // into the stripped string at the point where it is being checked.
831    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        // Find how many invert|normal markers appear before this escape
835
836        // find the number of invert strings and number of uninvert strings that have been
837        // inserted up to this point in the string
838        let num_invert = match_count / 2;
839        let num_normal = match_count - num_invert;
840
841        // calculate the index which this escape should be re-inserted at by adding
842        // its position in the stripped string to the total length of the ansi escapes
843        // (both highlighting and the ones from the original string).
844        // TODO: Add more docs to this
845        let mut pos = if !accurate && match_count % 2 == 1 {
846            // INFO: Its safe to unwrap here
847            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        // insert the escape back in
861        inverted.insert_str(pos, esc.1);
862
863        // increment the length of the escapes inserted back in
864        inserted_escs_len += esc.1.len();
865    }
866
867    inverted
868}
869
870/// Highlights the search match
871///
872/// The first return value returns the line that has all the search matches highlighted
873/// The second tells whether a search match was actually found
874#[cfg_attr(not(test), allow(dead_code))]
875pub(crate) fn highlight_line_matches(
876    line: &str,
877    query: &regex::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/// Return a index of an element from `search_idx` that will contain a search match and
916/// will be after the `upper_mark`
917///
918/// `jump` denotes how many indexes to jump through. For example if `search_idx` is
919/// `[5, 17, 25, 34, 42]` and `upper_mark` is at 7 and `jump` is set to 1 then this will
920/// return `Some(1)` which is the index of 17. If `n `is set to 3 it will return
921/// `Some(3)` which is index of 34.
922///
923/// If `jump` causes the index to overflow the length of the `search_idx`, the function will set it
924/// to wrap to the start of `search_idx`. Also if `search_idx` is empty, this will simply return None.
925///
926/// Setting `jump` equal to 0 causes a slight change in behaviour: it will also return the index of
927/// element if that element is equal to the current upper mark. In the above example lets say that
928/// `upper_mark` is at 17 and `jump` is set to 0 then this will return `Some(1)` as the
929/// `upper_mark` and element at index  are equal i.e 17.
930#[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    // Find the index of the match that's exactly after the upper_mark.
941    // If there isn't one, wrap to the first match in the file.
942    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"; // length = 33
1008            #[allow(clippy::cast_possible_truncation)]
1009            let last_movable_column: u16 = (QUERY_STRING.len() as u16) + 1; // 34
1010
1011            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            // Setup
1085            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            // We are currently at the very next column to the last char
1103
1104            // Check functionality of left arrow key
1105            // Pressing left arrow moves the cursor towards the beginning of string until it
1106            // reaches the first char after which pressing it further would not have any effect
1107            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            // Pressing Left arrow any more will not make any effect
1113            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            // Setup
1121            let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search();
1122            // Go to the 1st char
1123            search_opts.ev = Some(make_event_from_keycode(KeyCode::Home));
1124            handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
1125
1126            // Check functionality of right arrow key
1127            // Pressing right arrow moves the cursor towards the end of string until it
1128            // reaches the very next column to the last char after which pressing it further would not have any effect
1129            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            // Pressing right arrow any more will not make any effect
1135            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            // Setup
1144            let (mut search_opts, mut out, _last_movable_column, _) =
1145                pretest_setup_forward_search();
1146            // LAST_MOVABLE_COLUMN = _last_movable_column = 34
1147            #[allow(clippy::items_after_statements)]
1148            const LAST_MOVABLE_COLUMN: u16 = 34;
1149
1150            // Go to the 1st char
1151            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            // Jump right word by word
1162            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            // Pressing ctrl+right will not do anything any keep the cursor at the very next column
1168            // to the last char
1169            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            // Setup
1178            let (mut search_opts, mut out, _last_movable_column, _) =
1179                pretest_setup_forward_search();
1180            // LAST_MOVABLE_COLUMN = _last_movable_column = 34
1181            #[allow(clippy::items_after_statements)]
1182            const LAST_MOVABLE_COLUMN: u16 = 34;
1183
1184            // We are currently at the very next column to the last char
1185            let ev = Event::Key(KeyEvent {
1186                code: KeyCode::Left,
1187                kind: KeyEventKind::Press,
1188                modifiers: KeyModifiers::CONTROL,
1189                state: KeyEventState::NONE,
1190            });
1191
1192            // Jump right word by word
1193            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            // Pressing ctrl+left will not do anything and keep the cursor at the very first column
1199            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            // Try to recreate the behaviour of handle_key_press when new char is entered
1221            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"; // length = 33
1241            #[allow(clippy::cast_possible_truncation)]
1242            const LAST_MOVABLE_COLUMN: u16 = (QUERY_STRING.len() as u16) + 1; // 34
1243
1244            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            // Try to recreate the behaviour of handle_key_press when new char is entered
1256            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        // Smart case enabled + all lowercase -> case-insensitive
1277        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        // Smart case enabled + contains uppercase -> case-sensitive
1283        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        // Smart case disabled + lowercase -> case-sensitive
1289        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        // A sample index for mocking actual search index matches
1298        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        // generic escape code
1320        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            // NOTE: esc_pair means a single pair of ESC and NONE
1370
1371            #[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            // NOTE: esc_pair means a single pair of ESC and NONE
1462            #[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}