#![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)]
#[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(10)).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 set_match_indices(pager: &mut PagerState) {
let pattern = match pager.search_term.as_ref() {
Some(pat) => pat,
None => return,
};
pager.search_idx = pager
.formatted_lines
.iter()
.enumerate()
.filter_map(|(idx, line)| {
if pattern.is_match(line) {
Some(idx)
} else {
None
}
})
.collect();
}
pub fn highlight_line_matches(line: &str, query: ®ex::Regex) -> String {
let stripped_str = ANSI_REGEX.replace_all(line, "");
if !query.is_match(&stripped_str) {
return line.to_string();
}
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
}
#[cfg(feature = "search")]
pub fn next_match(ps: &mut PagerState) {
while let Some(y) = ps.search_idx.get(ps.search_mark) {
if *y < ps.upper_mark {
ps.search_mark += 1;
} else {
ps.upper_mark = *y as usize;
break;
}
}
}
#[cfg(test)]
mod tests {
use super::{highlight_line_matches, next_match, set_match_indices, 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 = vec![2, 10, 15, 17, 50];
for i in &pager.search_idx.clone() {
next_match(&mut pager);
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), result);
}
#[test]
fn test_set_match_indexes() {
let mut pager = PagerState::new().unwrap();
pager.lines = "\
Fusce suscipit, wisi nec facilisis facilisis, est dui fermentum leo, quis tempor ligula
erat quis odio. Nunc porta vulputate tellus. Nunc rutrum turpis sed pede. Sed
bibendum. Aliquam posuere. Nunc aliquet, augue nec adipiscing interdum, lacus tellus
malesuada massa, quis varius mi purus non odio. Pellentesque condimentum, magna ut
suscipit hendrerit, ipsum augue ornare nulla, non luctus diam neque sit amet urna.
Curabitur vulputate vestibulum lorem. Fusce sagittis, libero non molestie mollis, magna
orci ultrices dolor, at vulputate neque nulla lacinia eros. Sed id ligula quis est
convallis tempor. Curabitur lacinia pulvinar nibh. Nam a sapien."
.to_string();
pager.format_lines();
pager.search_term = Some(Regex::new(r"\Wa\w+\W").unwrap());
let res = vec![3, 7, 11];
set_match_indices(&mut pager);
assert_eq!(pager.search_idx, res);
}
#[test]
fn no_match() {
let orig = "no match";
let res = highlight_line_matches(orig, &Regex::new("test").unwrap());
assert_eq!(res, 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, 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,
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,
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, 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,
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,
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, 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,
format!(
"this {e}is a {i}test{n} again {e}yeah{nn} {i}test{n}",
e = ESC,
i = *INVERT,
n = *NORMAL,
nn = NONE
)
);
}
}