#![cfg_attr(docsrs, doc(cfg(feature = "search")))]
#![allow(unused_imports)]
use crate::minus_core::utils::{display, term};
use crate::screen::Screen;
use crate::{error::MinusError, input::HashedEventRegister, screen};
use crate::{LineNumbers, PagerState};
use crossterm::{
cursor::{self, MoveTo},
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
style::Attribute,
terminal::{Clear, ClearType},
};
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::BTreeSet;
use std::{
convert::{TryFrom, TryInto},
io::Write,
time::Duration,
};
use std::collections::hash_map::RandomState;
static INVERT: Lazy<String> = Lazy::new(|| Attribute::Reverse.to_string());
static NORMAL: Lazy<String> = Lazy::new(|| Attribute::NoReverse.to_string());
static ANSI_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new("[\\u001b\\u009b]\\[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]")
.unwrap()
});
static WORD: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"([\w_]+)|([-?~@#!$%^&*()-+={}\[\]:;\\|'/?<>.,"]+)|\W"#).unwrap());
#[derive(Clone, Copy, Debug, Eq)]
#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
#[allow(clippy::module_name_repetitions)]
pub enum SearchMode {
Forward,
Reverse,
Unknown,
}
impl Default for SearchMode {
fn default() -> Self {
Self::Unknown
}
}
impl PartialEq for SearchMode {
fn eq(&self, other: &Self) -> bool {
core::mem::discriminant(self) == core::mem::discriminant(other)
}
}
#[allow(clippy::module_name_repetitions)]
pub struct SearchOpts<'a> {
pub ev: Option<Event>,
pub string: String,
pub input_status: InputStatus,
pub cursor_position: u16,
pub search_mode: SearchMode,
pub word_index: Vec<u16>,
pub search_char: char,
pub rows: u16,
pub cols: u16,
pub incremental_search_options: Option<IncrementalSearchOpts<'a>>,
incremental_search_cache: Option<IncrementalSearchCache>,
compiled_regex: Option<Regex>,
}
pub struct IncrementalSearchOpts<'a> {
pub line_numbers: LineNumbers,
pub initial_upper_mark: usize,
pub screen: &'a Screen,
pub initial_left_mark: usize,
}
impl<'a> From<&'a PagerState> for IncrementalSearchOpts<'a> {
fn from(ps: &'a PagerState) -> Self {
Self {
line_numbers: ps.line_numbers,
initial_upper_mark: ps.upper_mark,
screen: &ps.screen,
initial_left_mark: ps.left_mark,
}
}
}
#[allow(clippy::fallible_impl_from)]
impl<'a> From<&'a PagerState> for SearchOpts<'a> {
fn from(ps: &'a PagerState) -> Self {
let search_char = if ps.search_state.search_mode == SearchMode::Forward {
'/'
} else if ps.search_state.search_mode == SearchMode::Reverse {
'?'
} else {
unreachable!();
};
let incremental_search_options = IncrementalSearchOpts::from(ps);
Self {
ev: None,
string: String::with_capacity(200),
input_status: InputStatus::Active,
cursor_position: 1,
word_index: Vec::with_capacity(200),
search_char,
rows: ps.rows.try_into().unwrap(),
cols: ps.cols.try_into().unwrap(),
incremental_search_options: Some(incremental_search_options),
incremental_search_cache: None,
compiled_regex: None,
search_mode: ps.search_state.search_mode,
}
}
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum InputStatus {
Confirmed,
Cancelled,
Active,
}
impl InputStatus {
#[must_use]
pub const fn done(&self) -> bool {
matches!(self, Self::Cancelled | Self::Confirmed)
}
}
pub(crate) struct FetchInputResult {
pub(crate) string: String,
pub(crate) incremental_search_result: Option<IncrementalSearchCache>,
pub(crate) compiled_regex: Option<Regex>,
}
impl FetchInputResult {
const fn new_empty() -> Self {
Self {
string: String::new(),
incremental_search_result: None,
compiled_regex: None,
}
}
}
pub(crate) struct IncrementalSearchCache {
pub(crate) formatted_lines: Vec<String>,
pub(crate) search_mark: usize,
pub(crate) search_idx: BTreeSet<usize>,
pub(crate) upper_mark: usize,
}
fn run_incremental_search<'a, F, O>(
out: &mut O,
so: &'a SearchOpts<'a>,
incremental_search_condition: F,
) -> crate::Result<Option<IncrementalSearchCache>>
where
O: Write,
F: Fn(&'a SearchOpts) -> bool,
{
if so.incremental_search_options.is_none() {
return Ok(None);
}
let iso = so.incremental_search_options.as_ref().unwrap();
let should_proceed = so.compiled_regex.is_some() && incremental_search_condition(so);
let reset_screen = |out: &mut O, so: &SearchOpts<'_>| -> crate::Result {
display::write_text_checked(
out,
&iso.screen.formatted_lines,
iso.initial_upper_mark,
so.rows.into(),
so.cols.into(),
iso.screen.line_wrapping,
iso.initial_left_mark,
iso.line_numbers,
iso.screen.line_count(),
)?;
Ok(())
};
if so.incremental_search_cache.is_some() && !should_proceed {
reset_screen(out, so)?;
return Ok(None);
}
if !should_proceed {
return Ok(None);
}
let (buffer, format_result) = screen::make_format_lines(
&iso.screen.orig_text,
iso.line_numbers,
so.cols.into(),
iso.screen.line_wrapping,
&so.compiled_regex,
);
let position_of_next_match =
next_nth_match(&format_result.append_search_idx, iso.initial_upper_mark, 0);
let upper_mark;
if let Some(pnm) = position_of_next_match {
upper_mark = *format_result.append_search_idx.iter().nth(pnm).unwrap();
display::write_text_checked(
out,
&buffer,
upper_mark,
so.rows.into(),
so.cols.into(),
iso.screen.line_wrapping,
iso.initial_left_mark,
iso.line_numbers,
iso.screen.line_count(),
)?;
} else {
reset_screen(out, so)?;
return Ok(None);
}
Ok(Some(IncrementalSearchCache {
formatted_lines: buffer,
search_mark: position_of_next_match.unwrap(),
upper_mark,
search_idx: format_result.append_search_idx,
}))
}
#[allow(clippy::too_many_lines)]
fn handle_key_press<O, F>(
out: &mut O,
so: &mut SearchOpts<'_>,
incremental_search_condition: F,
) -> crate::Result
where
O: Write,
F: Fn(&SearchOpts<'_>) -> bool,
{
const FIRST_AVAILABLE_COLUMN: u16 = 1;
let last_available_column: u16 = so.string.len().saturating_add(1).try_into().unwrap();
if so.ev.is_none() {
return Ok(());
}
let populate_word_index = |so: &mut SearchOpts<'_>| {
so.word_index = WORD
.find_iter(&so.string)
.map(|c| c.start().saturating_add(1).try_into().unwrap())
.collect::<Vec<u16>>();
};
let refresh_display = |out: &mut O, so: &mut SearchOpts<'_>| -> Result<(), MinusError> {
so.compiled_regex = Regex::new(&so.string).ok();
so.incremental_search_cache =
run_incremental_search(out, so, incremental_search_condition)?;
term::move_cursor(out, 0, so.rows, false)?;
write!(
out,
"\r{}{}{}",
Clear(ClearType::CurrentLine),
so.search_char,
so.string,
)?;
Ok(())
};
match so.ev.as_ref().unwrap() {
Event::Key(KeyEvent {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
..
}) => {
so.string.clear();
so.input_status = InputStatus::Cancelled;
}
Event::Key(KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
..
}) => {
if so.cursor_position == FIRST_AVAILABLE_COLUMN {
return Ok(());
}
so.cursor_position = so.cursor_position.saturating_sub(1);
so.string
.remove(so.cursor_position.saturating_sub(1).into());
populate_word_index(so);
refresh_display(out, so)?;
term::move_cursor(out, so.cursor_position, so.rows, false)?;
out.flush()?;
}
Event::Key(KeyEvent {
code: KeyCode::Delete,
modifiers: KeyModifiers::NONE,
..
}) => {
if so.cursor_position >= last_available_column {
return Ok(());
}
so.cursor_position = so.cursor_position.saturating_sub(1);
so.string
.remove(<u16 as Into<usize>>::into(so.cursor_position));
populate_word_index(so);
so.cursor_position = so.cursor_position.saturating_add(1);
refresh_display(out, so)?;
term::move_cursor(out, so.cursor_position, so.rows, false)?;
out.flush()?;
}
Event::Key(KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
}) => {
so.input_status = InputStatus::Confirmed;
}
Event::Key(KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
}) => {
if so.cursor_position == FIRST_AVAILABLE_COLUMN {
return Ok(());
}
so.cursor_position = so.cursor_position.saturating_sub(1);
term::move_cursor(out, so.cursor_position, so.rows, true)?;
}
Event::Key(KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::CONTROL,
..
}) => {
so.cursor_position = *so
.word_index
.iter()
.rfind(|c| c < &&so.cursor_position)
.unwrap_or(&FIRST_AVAILABLE_COLUMN);
term::move_cursor(out, so.cursor_position, so.rows, true)?;
}
Event::Key(KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
}) => {
if so.cursor_position >= last_available_column {
return Ok(());
}
so.cursor_position = so.cursor_position.saturating_add(1);
term::move_cursor(out, so.cursor_position, so.rows, true)?;
}
Event::Key(KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::CONTROL,
..
}) => {
so.cursor_position = *so
.word_index
.iter()
.find(|c| c > &&so.cursor_position)
.unwrap_or(&last_available_column);
term::move_cursor(out, so.cursor_position, so.rows, true)?;
}
Event::Key(KeyEvent {
code: KeyCode::Home,
modifiers: KeyModifiers::NONE,
..
}) => {
so.cursor_position = 1;
term::move_cursor(out, 1, so.rows, true)?;
}
Event::Key(KeyEvent {
code: KeyCode::End,
modifiers: KeyModifiers::NONE,
..
}) => {
so.cursor_position = so.string.len().saturating_add(1).try_into().unwrap();
term::move_cursor(out, so.cursor_position, so.rows, true)?;
}
Event::Key(event) => {
if let KeyCode::Char(c) = event.code {
so.string
.insert(so.cursor_position.saturating_sub(1).into(), c);
populate_word_index(so);
refresh_display(out, so)?;
so.cursor_position = so.cursor_position.saturating_add(1);
term::move_cursor(out, so.cursor_position, so.rows, false)?;
out.flush()?;
}
}
_ => return Ok(()),
}
Ok(())
}
#[cfg(feature = "search")]
pub(crate) fn fetch_input(
out: &mut impl std::io::Write,
ps: &PagerState,
) -> Result<FetchInputResult, MinusError> {
let search_char = if ps.search_state.search_mode == SearchMode::Forward {
'/'
} else {
'?'
};
term::move_cursor(out, 0, ps.rows.try_into().unwrap(), false)?;
write!(
out,
"{}{}{}",
Clear(ClearType::CurrentLine),
search_char,
cursor::Show
)?;
out.flush()?;
let mut search_opts = SearchOpts::from(ps);
loop {
if event::poll(Duration::from_millis(100)).map_err(|e| MinusError::HandleEvent(e.into()))? {
let ev = event::read().map_err(|e| MinusError::HandleEvent(e.into()))?;
search_opts.ev = Some(ev);
handle_key_press(
out,
&mut search_opts,
&ps.search_state.incremental_search_condition,
)?;
search_opts.ev = None;
}
if search_opts.input_status.done() {
break;
}
}
term::move_cursor(out, 0, ps.rows.try_into().unwrap(), false)?;
write!(out, "{}{}", Clear(ClearType::CurrentLine), cursor::Hide)?;
out.flush()?;
let fetch_input_result = match search_opts.input_status {
InputStatus::Active => unreachable!(),
InputStatus::Cancelled => FetchInputResult::new_empty(),
InputStatus::Confirmed => FetchInputResult {
string: search_opts.string,
incremental_search_result: search_opts.incremental_search_cache,
compiled_regex: search_opts.compiled_regex,
},
};
Ok(fetch_input_result)
}
pub(crate) fn highlight_line_matches(
line: &str,
query: ®ex::Regex,
accurate: bool,
) -> (String, bool) {
let stripped_str = ANSI_REGEX.replace_all(line, "");
if !query.is_match(&stripped_str) {
return (line.to_string(), false);
}
let mut sum_width = 0;
let escapes = ANSI_REGEX
.find_iter(line)
.map(|escape| {
let start = escape.start();
let as_str = escape.as_str();
let ret = (start - sum_width, as_str);
sum_width += as_str.len();
ret
})
.collect::<Vec<_>>();
let matches = query
.find_iter(&stripped_str)
.flat_map(|c| [c.start(), c.end()])
.collect::<Vec<_>>();
let mut inverted = query
.replace_all(&stripped_str, |caps: ®ex::Captures| {
format!("{}{}{}", *INVERT, &caps[0], *NORMAL)
})
.to_string();
let mut inserted_escs_len = 0;
for esc in escapes {
let match_count = matches.iter().take_while(|m| **m <= esc.0).count();
let num_invert = match_count / 2;
let num_normal = match_count - num_invert;
let mut pos = if !accurate && match_count % 2 == 1 {
matches.get(match_count).unwrap()
+ NORMAL.len()
+ inserted_escs_len
+ (num_invert * INVERT.len())
+ (num_normal * NORMAL.len())
} else {
esc.0 + inserted_escs_len + (num_invert * INVERT.len()) + (num_normal * NORMAL.len())
};
if match_count % 2 == 1 {
pos = pos.saturating_sub(1);
}
inverted.insert_str(pos, esc.1);
inserted_escs_len += esc.1.len();
}
(inverted, true)
}
#[must_use]
pub(crate) fn next_nth_match(
search_idx: &BTreeSet<usize>,
upper_mark: usize,
jump: usize,
) -> Option<usize> {
if search_idx.is_empty() {
return None;
}
let mut position_of_next_match;
if let Some(nearest_idx) = search_idx.iter().position(|i| {
if jump == 0 {
*i >= upper_mark
} else {
*i > upper_mark
}
}) {
if jump == 0 {
position_of_next_match = nearest_idx;
} else {
position_of_next_match = nearest_idx.saturating_add(jump).saturating_sub(1);
}
if position_of_next_match > search_idx.len().saturating_sub(1) {
position_of_next_match = search_idx.len().saturating_sub(1);
}
} else {
position_of_next_match = search_idx.len().saturating_sub(1);
}
Some(position_of_next_match)
}
#[cfg(test)]
mod tests {
mod input_handling {
use crate::{
search::{handle_key_press, InputStatus, SearchOpts},
SearchMode,
};
use crossterm::{
cursor::MoveTo,
event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers},
terminal::{Clear, ClearType},
};
use std::{convert::TryInto, io::Write};
fn new_search_opts(sm: SearchMode) -> SearchOpts<'static> {
let search_char = match sm {
SearchMode::Forward => '/',
SearchMode::Reverse => '?',
SearchMode::Unknown => unreachable!(),
};
SearchOpts {
ev: None,
string: String::with_capacity(200),
input_status: InputStatus::Active,
cursor_position: 1,
word_index: Vec::with_capacity(200),
search_char,
rows: 25,
cols: 100,
incremental_search_options: None,
incremental_search_cache: None,
compiled_regex: None,
search_mode: sm,
}
}
const fn make_event_from_keycode(kc: KeyCode) -> Event {
Event::Key(KeyEvent {
code: kc,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
state: KeyEventState::NONE,
})
}
fn pretest_setup_forward_search() -> (SearchOpts<'static>, Vec<u8>, u16, &'static str) {
const QUERY_STRING: &str = "this is@complex-text_search?query"; #[allow(clippy::cast_possible_truncation)]
let last_movable_column: u16 = (QUERY_STRING.len() as u16) + 1;
let mut search_opts = new_search_opts(SearchMode::Forward);
let mut out = Vec::with_capacity(1500);
for c in QUERY_STRING.chars() {
search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
}
assert_eq!(search_opts.cursor_position, last_movable_column);
(search_opts, out, last_movable_column, QUERY_STRING)
}
#[test]
fn input_sequential_text() {
let mut search_opts = new_search_opts(SearchMode::Forward);
let mut out = Vec::with_capacity(1500);
for (i, c) in "text search matches".chars().enumerate() {
search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.input_status, InputStatus::Active);
assert_eq!(search_opts.cursor_position as usize, i + 2);
}
search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.word_index, vec![1, 5, 6, 12, 13]);
assert_eq!(&search_opts.string, "text search matches");
assert_eq!(search_opts.input_status, InputStatus::Confirmed);
}
#[test]
fn input_complex_sequential_text() {
let mut search_opts = new_search_opts(SearchMode::Forward);
let mut out = Vec::with_capacity(1500);
for (i, c) in "this is@complex-text_search?query".chars().enumerate() {
search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.input_status, InputStatus::Active);
assert_eq!(search_opts.cursor_position as usize, i + 2);
}
search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.word_index, vec![1, 5, 6, 8, 9, 16, 17, 28, 29]);
assert_eq!(&search_opts.string, "this is@complex-text_search?query");
assert_eq!(search_opts.input_status, InputStatus::Confirmed);
}
#[test]
fn home_end_keys() {
let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search();
search_opts.ev = Some(make_event_from_keycode(KeyCode::Home));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position as usize, 1);
search_opts.ev = Some(make_event_from_keycode(KeyCode::End));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, last_movable_column);
}
#[test]
fn basic_left_arrow_movement() {
const FIRST_MOVABLE_COLUMN: u16 = 1;
let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search();
let query_string_length = last_movable_column - 1;
for i in (FIRST_MOVABLE_COLUMN..=query_string_length).rev() {
search_opts.ev = Some(make_event_from_keycode(KeyCode::Left));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, i);
}
search_opts.ev = Some(make_event_from_keycode(KeyCode::Left));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, FIRST_MOVABLE_COLUMN);
}
#[test]
fn basic_right_arrow_movement() {
let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search();
search_opts.ev = Some(make_event_from_keycode(KeyCode::Home));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
for i in 2..=last_movable_column {
search_opts.ev = Some(make_event_from_keycode(KeyCode::Right));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, i);
}
search_opts.ev = Some(make_event_from_keycode(KeyCode::Right));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, last_movable_column);
}
#[test]
fn right_jump_by_word() {
const JUMP_COLUMNS: [u16; 10] = [1, 5, 6, 8, 9, 16, 17, 28, 29, LAST_MOVABLE_COLUMN];
let (mut search_opts, mut out, _last_movable_column, _) =
pretest_setup_forward_search();
#[allow(clippy::items_after_statements)]
const LAST_MOVABLE_COLUMN: u16 = 34;
search_opts.ev = Some(make_event_from_keycode(KeyCode::Home));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
let ev = Event::Key(KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
state: KeyEventState::NONE,
});
for i in &JUMP_COLUMNS[1..] {
search_opts.ev = Some(ev.clone());
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, *i);
}
search_opts.ev = Some(ev);
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, LAST_MOVABLE_COLUMN);
}
#[test]
fn left_jump_by_word() {
const JUMP_COLUMNS: [u16; 10] = [1, 5, 6, 8, 9, 16, 17, 28, 29, LAST_MOVABLE_COLUMN];
let (mut search_opts, mut out, _last_movable_column, _) =
pretest_setup_forward_search();
#[allow(clippy::items_after_statements)]
const LAST_MOVABLE_COLUMN: u16 = 34;
let ev = Event::Key(KeyEvent {
code: KeyCode::Left,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
state: KeyEventState::NONE,
});
for i in (JUMP_COLUMNS[..(JUMP_COLUMNS.len() - 1)]).iter().rev() {
search_opts.ev = Some(ev.clone());
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, *i);
}
search_opts.ev = Some(ev);
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.cursor_position, JUMP_COLUMNS[0]);
}
#[test]
fn esc_key() {
let (mut search_opts, mut out, _, _) = pretest_setup_forward_search();
search_opts.ev = Some(make_event_from_keycode(KeyCode::Esc));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
assert_eq!(search_opts.input_status, InputStatus::Cancelled);
}
#[test]
fn forward_sequential_text_input_screen_data() {
let (search_opts, out, _last_movable_column, query_string) =
pretest_setup_forward_search();
let mut result_out = Vec::with_capacity(1500);
let mut string = String::with_capacity(query_string.len());
let mut cursor_position: u16 = 1;
for c in query_string.chars() {
string.push(c);
cursor_position = cursor_position.saturating_add(1);
write!(
result_out,
"{move_to_prompt}\r{clear_line}/{string}{move_to_position}",
move_to_prompt = MoveTo(0, search_opts.rows),
clear_line = Clear(ClearType::CurrentLine),
move_to_position = MoveTo(cursor_position, search_opts.rows),
)
.unwrap();
}
assert_eq!(out, result_out);
}
#[test]
fn backward_sequential_text_input_screen_data() {
const QUERY_STRING: &str = "this is@complex-text_search?query"; #[allow(clippy::cast_possible_truncation)]
const LAST_MOVABLE_COLUMN: u16 = (QUERY_STRING.len() as u16) + 1;
let mut search_opts = new_search_opts(SearchMode::Reverse);
let mut out = Vec::with_capacity(1500);
for c in QUERY_STRING.chars() {
search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c)));
handle_key_press(&mut out, &mut search_opts, |_| false).unwrap();
}
assert_eq!(search_opts.cursor_position, LAST_MOVABLE_COLUMN);
let mut result_out = Vec::with_capacity(1500);
let mut string = String::with_capacity(QUERY_STRING.len());
let mut cursor_position: u16 = 1;
for c in QUERY_STRING.chars() {
string.push(c);
cursor_position = cursor_position.saturating_add(1);
write!(
result_out,
"{move_to_prompt}\r{clear_line}?{string}{move_to_position}",
move_to_prompt = MoveTo(0, search_opts.rows),
clear_line = Clear(ClearType::CurrentLine),
move_to_position = MoveTo(cursor_position, search_opts.rows),
)
.unwrap();
}
assert_eq!(out, result_out);
}
}
#[test]
fn test_next_match() {
let search_idx = std::collections::BTreeSet::from([2, 10, 15, 17, 50]);
let mut upper_mark = 0;
let mut search_mark;
for (i, v) in search_idx.iter().enumerate() {
search_mark = super::next_nth_match(&search_idx, upper_mark, 1);
assert_eq!(search_mark, Some(i));
let next_upper_mark = *search_idx.iter().nth(search_mark.unwrap()).unwrap();
assert_eq!(next_upper_mark, *v);
upper_mark = next_upper_mark;
}
}
#[allow(clippy::trivial_regex)]
mod highlighting {
use std::collections::BTreeSet;
use crate::search::{highlight_line_matches, next_nth_match, INVERT, NORMAL};
use crate::PagerState;
use crossterm::style::Attribute;
use regex::Regex;
const ESC: &str = "\x1b[34m";
const NONE: &str = "\x1b[0m";
mod consistent {
use super::*;
#[test]
fn test_highlight_matches() {
let line = "Integer placerat tristique nisl. placerat non mollis, magna orci dolor, placerat at vulputate neque nulla lacinia eros.".to_string();
let pat = Regex::new(r"\W\w+t\W").unwrap();
let result = format!(
"Integer{inverse} placerat {noinverse}tristique nisl.\
{inverse} placerat {noinverse}non mollis, magna orci dolor,\
{inverse} placerat {noinverse}at vulputate neque nulla lacinia \
eros.",
inverse = Attribute::Reverse,
noinverse = Attribute::NoReverse
);
assert_eq!(highlight_line_matches(&line, &pat, false).0, result);
}
#[test]
fn no_match() {
let orig = "no match";
let res = highlight_line_matches(orig, &Regex::new("test").unwrap(), false);
assert_eq!(res.0, orig.to_string());
}
#[test]
fn single_match_no_esc() {
let res =
highlight_line_matches("this is a test", &Regex::new(" a ").unwrap(), false);
assert_eq!(res.0, format!("this is{} a {}test", *INVERT, *NORMAL));
}
#[test]
fn multi_match_no_esc() {
let res = highlight_line_matches(
"test another test",
&Regex::new("test").unwrap(),
false,
);
assert_eq!(
res.0,
format!("{i}test{n} another {i}test{n}", i = *INVERT, n = *NORMAL)
);
}
#[test]
fn esc_pair_outside_match() {
let res = highlight_line_matches(
&format!("{ESC}color{NONE} and test"),
&Regex::new("test").unwrap(),
false,
);
assert_eq!(
res.0,
format!("{}color{} and {}test{}", ESC, NONE, *INVERT, *NORMAL)
);
}
#[test]
fn esc_pair_end_in_match() {
let orig = format!("this {ESC}is a te{NONE}st");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
assert_eq!(
res.0,
format!("this {}is a {}test{}{}", ESC, *INVERT, *NORMAL, NONE)
);
}
#[test]
fn esc_pair_start_in_match() {
let orig = format!("this is a te{ESC}st again{NONE}");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
assert_eq!(
res.0,
format!("this is a {}test{}{ESC} again{}", *INVERT, *NORMAL, NONE)
);
}
#[test]
fn esc_pair_around_match() {
let orig = format!("this is {ESC}a test again{NONE}");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
assert_eq!(
res.0,
format!("this is {}a {}test{} again{}", ESC, *INVERT, *NORMAL, NONE)
);
}
#[test]
fn esc_pair_within_match() {
let orig = format!("this is a t{ESC}es{NONE}t again");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
assert_eq!(
res.0,
format!("this is a {}test{}{ESC}{NONE} again", *INVERT, *NORMAL)
);
}
#[test]
fn multi_escape_match() {
let orig = format!("this {ESC}is a te{NONE}st again {ESC}yeah{NONE} test",);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), false);
assert_eq!(
res.0,
format!(
"this {e}is a {i}test{n}{nn} again {e}yeah{nn} {i}test{n}",
e = ESC,
i = *INVERT,
n = *NORMAL,
nn = NONE
)
);
}
}
mod accurate {
use super::*;
#[test]
fn correct_ascii_sequence_placement() {
let orig = format!(
"{ESC}test{NONE} this {ESC}is a te{NONE}st again {ESC}yeah{NONE} test",
);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
assert_eq!(
res.0,
format!(
"{i}{e}test{n}{nn} this {e}is a {i}te{NONE}st{n} again {e}yeah{nn} {i}test{n}",
e = ESC,
i = *INVERT,
n = *NORMAL,
nn = NONE
)
);
}
#[test]
fn esc_pair_outside_match() {
let res = highlight_line_matches(
&format!("{ESC}color{NONE} and test"),
&Regex::new("test").unwrap(),
true,
);
assert_eq!(
res.0,
format!("{}color{} and {}test{}", ESC, NONE, *INVERT, *NORMAL)
);
}
#[test]
fn esc_pair_end_in_match() {
let orig = format!("this {ESC}is a te{NONE}st");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
assert_eq!(
res.0,
format!("this {ESC}is a {}te{NONE}st{}", *INVERT, *NORMAL)
);
}
#[test]
fn esc_pair_start_in_match() {
let orig = format!("this is a te{ESC}st again{NONE}");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
assert_eq!(
res.0,
format!("this is a {}te{ESC}st{} again{NONE}", *INVERT, *NORMAL)
);
}
#[test]
fn esc_pair_around_match() {
let orig = format!("this is {ESC}a test again{NONE}");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
assert_eq!(
res.0,
format!("this is {ESC}a {}test{} again{NONE}", *INVERT, *NORMAL)
);
}
#[test]
fn esc_pair_within_match() {
let orig = format!("this is a t{ESC}es{NONE}t again");
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
assert_eq!(
res.0,
format!("this is a {}t{ESC}es{NONE}t{} again", *INVERT, *NORMAL)
);
}
#[test]
fn multi_escape_match() {
let orig = format!("this {ESC}is a te{NONE}st again {ESC}yeah{NONE} test",);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap(), true);
assert_eq!(
res.0,
format!(
"this {e}is a {i}te{nn}st{n} again {e}yeah{nn} {i}test{n}",
e = ESC,
i = *INVERT,
n = *NORMAL,
nn = NONE
)
);
}
}
}
}