#![allow(unused_imports)]
use crate::error::MinusError;
use crate::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::{convert::TryFrom, time::Duration};
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()
});
#[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)
}
}
#[cfg(feature = "search")]
pub fn fetch_input(
out: &mut impl std::io::Write,
search_mode: SearchMode,
rows: usize,
) -> Result<String, MinusError> {
#[allow(clippy::cast_possible_truncation)]
write!(
out,
"{}{}{}{}",
MoveTo(0, rows as u16),
Clear(ClearType::CurrentLine),
if search_mode == SearchMode::Forward {
"/"
} else {
"?"
},
cursor::Show
)?;
out.flush()?;
let mut string = String::new();
loop {
if event::poll(Duration::from_millis(100)).map_err(|e| MinusError::HandleEvent(e.into()))? {
match event::read().map_err(|e| MinusError::HandleEvent(e.into()))? {
Event::Key(KeyEvent {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
}) => {
write!(out, "{}", cursor::Hide)?;
return Ok(String::new());
}
Event::Key(KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
}) => {
string.pop();
write!(out, "\r{}/{}", Clear(ClearType::CurrentLine), string)?;
out.flush()?;
}
Event::Key(KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
}) => {
write!(out, "{}", cursor::Hide)?;
return Ok(string);
}
Event::Key(event) => {
if let KeyCode::Char(c) = event.code {
string.push(c);
write!(out, "\r/{}", string)?;
out.flush()?;
}
}
_ => continue,
}
}
}
}
pub fn highlight_line_matches(line: &str, query: ®ex::Regex) -> (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();
if match_count % 2 == 1 {
continue;
}
let num_invert = match_count / 2;
let num_normal = match_count - num_invert;
let pos =
esc.0 + inserted_escs_len + (num_invert * INVERT.len()) + (num_normal * NORMAL.len());
inverted.insert_str(pos, esc.1);
inserted_escs_len += esc.1.len();
}
(inverted, true)
}
pub fn next_nth_match(ps: &mut PagerState, n: usize) {
if let Some(nearest_idx) = ps.search_idx.iter().position(|i| *i > ps.upper_mark) {
ps.search_mark = nearest_idx.saturating_add(n).saturating_sub(1);
if ps.search_mark > ps.search_idx.len().saturating_sub(1) {
ps.search_mark = ps.search_idx.len().saturating_sub(1);
}
} else {
ps.search_mark = ps.search_idx.len().saturating_sub(1);
}
if let Some(idx) = ps.search_idx.iter().nth(ps.search_mark) {
ps.upper_mark = *idx;
}
}
#[allow(clippy::trivial_regex)]
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::{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";
#[test]
fn test_next_match() {
let mut pager = PagerState::new().unwrap();
pager.search_mark = 0;
pager.search_idx = BTreeSet::from([2, 10, 15, 17, 50]);
for i in &pager.search_idx.clone() {
next_nth_match(&mut pager, 1);
assert_eq!(pager.upper_mark, *i as usize);
pager.search_mark += 1;
}
}
#[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).0, result);
}
#[test]
fn no_match() {
let orig = "no match";
let res = highlight_line_matches(orig, &Regex::new("test").unwrap());
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());
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());
assert_eq!(
res.0,
format!("{i}test{n} another {i}test{n}", i = *INVERT, n = *NORMAL)
);
}
#[test]
fn esc_outside_match() {
let res = highlight_line_matches(
&format!("{}color{} and test", ESC, NONE),
&Regex::new("test").unwrap(),
);
assert_eq!(
res.0,
format!("{}color{} and {}test{}", ESC, NONE, *INVERT, *NORMAL)
);
}
#[test]
fn esc_end_in_match() {
let orig = format!("this {}is a te{}st", ESC, NONE);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap());
assert_eq!(
res.0,
format!("this {}is a {}test{}", ESC, *INVERT, *NORMAL)
);
}
#[test]
fn esc_start_in_match() {
let orig = format!("this is a te{}st again{}", ESC, NONE);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap());
assert_eq!(
res.0,
format!("this is a {}test{} again{}", *INVERT, *NORMAL, NONE)
);
}
#[test]
fn esc_around_match() {
let orig = format!("this is {}a test again{}", ESC, NONE);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap());
assert_eq!(
res.0,
format!("this is {}a {}test{} again{}", ESC, *INVERT, *NORMAL, NONE)
);
}
#[test]
fn esc_within_match() {
let orig = format!("this is a t{}es{}t again", ESC, NONE);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap());
assert_eq!(res.0, format!("this is a {}test{} again", *INVERT, *NORMAL));
}
#[test]
fn multi_escape_match() {
let orig = format!(
"this {e}is a te{n}st again {e}yeah{n} test",
e = ESC,
n = NONE
);
let res = highlight_line_matches(&orig, &Regex::new("test").unwrap());
assert_eq!(
res.0,
format!(
"this {e}is a {i}test{n} again {e}yeah{nn} {i}test{n}",
e = ESC,
i = *INVERT,
n = *NORMAL,
nn = NONE
)
);
}
}