use std::collections::HashSet;
use std::env;
use std::ffi::OsStr;
use std::fs::{read_dir, File, ReadDir};
use std::io::{self, BufRead, BufReader, Lines};
use std::iter::Enumerate;
use std::path::{Path, PathBuf};
use std::slice::Iter;
use colored::Colorize;
use regex::Regex;
use tiny_bail::or_return_quiet;
pub mod history;
pub mod open;
pub const USE_COLOR: u32 = 1;
pub const SEARCH_HIDDEN: u32 = 2;
pub const SINGLE_MATCH: u32 = 4;
const DEFAULT_MATCH_PADDING: usize = 15;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
pub file: PathBuf,
pub line: usize,
pub text: String,
}
pub struct Builder<'a> {
pattern: &'a Regex,
directory: &'a Path,
file_pattern: Option<&'a Regex>,
excluded_dirs: Vec<String>,
max_depth: Option<usize>,
flags: u32,
}
impl<'a> Builder<'a> {
pub fn new(pattern: &'a Regex, directory: &'a Path) -> Self {
Self {
pattern,
directory,
file_pattern: None,
excluded_dirs: vec![],
max_depth: None,
flags: 0,
}
}
pub fn set_file_pattern(&mut self, pattern: &'a Regex) {
self.file_pattern = Some(pattern);
}
pub fn exclude_dir(&mut self, dir: &str) {
self.excluded_dirs.push(dir.trim_end_matches("/").to_string());
}
pub fn set_depth(&mut self, depth: usize) {
self.max_depth = Some(depth);
}
pub fn set_flags(&mut self, flags: u32) {
self.flags |= flags;
}
pub fn build(self) -> io::Result<Searcher<'a>> {
Searcher::build(
self.pattern,
self.directory,
self.file_pattern,
self.excluded_dirs,
self.max_depth,
self.flags,
)
}
}
pub struct Searcher<'a> {
pattern: &'a Regex,
file_pattern: Option<&'a Regex>,
excluded_dirs: HashSet<PathBuf>,
max_depth: Option<usize>,
flags: u32,
match_padding: usize,
readers: Vec<ReadDir>,
current_scanner: Option<FileScanner<'a>>,
}
impl<'a> Searcher<'a> {
fn build(
pattern: &'a Regex,
directory: &'a Path,
file_pattern: Option<&'a Regex>,
excluded_dirs: Vec<String>,
max_depth: Option<usize>,
flags: u32,
) -> io::Result<Self> {
let reader = read_dir(directory)?;
let excluded_dirs = Rebaser::new(directory, &excluded_dirs)
.filter(|p| match p.as_ref() {
Ok(_) => true,
Err(e) => e.kind() != io::ErrorKind::NotFound,
})
.collect::<io::Result<HashSet<PathBuf>>>()?;
Ok(Self {
pattern,
file_pattern,
excluded_dirs,
max_depth: max_depth.map(|x| x + 1),
flags,
match_padding: determine_match_padding().unwrap_or(DEFAULT_MATCH_PADDING),
readers: vec![reader],
current_scanner: None,
})
}
fn push_directory(&mut self, directory: PathBuf) {
if let Some(depth) = self.max_depth {
if self.readers.len() == depth {
return;
}
}
let resolved_directory = or_return_quiet!(directory.canonicalize());
if self.excluded_dirs.contains(&resolved_directory) {
return;
}
if let Ok(reader) = read_dir(directory) {
self.readers.push(reader);
}
}
fn next_match_from_file(&mut self) -> Option<Location> {
let location = self.current_scanner.as_mut()?.next();
if location.is_none() {
self.current_scanner = None;
}
location
}
}
impl Iterator for Searcher<'_> {
type Item = Location;
fn next(&mut self) -> Option<Location> {
if let Some(location) = self.next_match_from_file() {
return Some(location);
}
while let Some(current_reader) = self.readers.last_mut() {
let path = match current_reader.next() {
Some(Ok(ent)) => ent.path(),
_ => {
self.readers.pop();
continue;
}
};
if let Some(first_char) = path
.file_name()
.and_then(OsStr::to_str)
.and_then(|name| name.chars().next())
{
if first_char == '.' && self.flags & SEARCH_HIDDEN == 0 {
continue;
}
}
if path.is_dir() {
self.push_directory(path);
continue;
}
if let Some(file_pattern) = self.file_pattern {
if file_pattern
.find(path.file_name().unwrap().to_string_lossy().as_ref())
.is_none()
{
continue;
}
}
self.current_scanner = FileScanner::build(path, self.pattern, self.flags, self.match_padding);
if let Some(location) = self.next_match_from_file() {
return Some(location);
}
}
None
}
}
struct FileScanner<'a> {
path: PathBuf,
pattern: &'a Regex,
flags: u32,
match_padding: usize,
lines: Enumerate<Lines<BufReader<File>>>,
current_search: Option<LineSearch>,
found_match: bool,
}
impl<'a> FileScanner<'a> {
fn build(path: PathBuf, pattern: &'a Regex, flags: u32, match_padding: usize) -> Option<Self> {
let handle = File::open(&path).ok()?;
let reader = BufReader::new(handle);
Some(Self {
path,
pattern,
flags,
match_padding,
lines: reader.lines().enumerate(),
current_search: None,
found_match: false,
})
}
fn match_from_current_line(&mut self) -> Option<Location> {
let current_search = self.current_search.as_mut()?;
let rest_of_line = ¤t_search.line[current_search.position..];
let pattern_match = match self.pattern.find(rest_of_line) {
Some(m) => m,
None => {
self.current_search = None;
return None;
}
};
let start = pattern_match.start();
let end = pattern_match.end();
let mut text = rest_of_line[start..end].to_string();
if self.flags & USE_COLOR != 0 {
text = text.bright_red().to_string();
}
let start_char = get_char_index(rest_of_line, start) + current_search.char_position;
let end_char = get_char_index(rest_of_line, end) + current_search.char_position;
let chars: Vec<char> = current_search.line.chars().collect();
let snippet = LineSnippet::from_match(&chars, start_char, end_char, self.match_padding);
if snippet.left < start_char {
let prev_text: String = chars[snippet.left..start_char].iter().collect();
text = format!("{prev_text}{text}");
if snippet.more_on_left {
text = format!("... {text}");
}
}
if snippet.right > end_char {
let next_text: String = chars[end_char..snippet.right].iter().collect();
text = format!("{text}{next_text}");
if snippet.more_on_right {
text = format!("{text} ...");
}
}
current_search.position += end;
current_search.char_position = end_char;
self.found_match = true;
Some(Location {
file: self.path.clone(),
line: current_search.line_no,
text: text.trim().to_string(),
})
}
}
impl Iterator for FileScanner<'_> {
type Item = Location;
fn next(&mut self) -> Option<Location> {
if self.flags & SINGLE_MATCH != 0 && self.found_match {
return None;
}
if let Some(location) = self.match_from_current_line() {
return Some(location);
}
loop {
let (line_no, line) = match self.lines.next() {
Some((i, Ok(l))) => (i + 1, l),
_ => return None,
};
self.current_search = Some(LineSearch {
line_no,
line,
position: 0,
char_position: 0,
});
if let Some(location) = self.match_from_current_line() {
return Some(location);
}
}
}
}
struct LineSearch {
line_no: usize,
line: String,
position: usize,
char_position: usize,
}
struct LineSnippet {
left: usize,
right: usize,
more_on_left: bool,
more_on_right: bool,
}
impl LineSnippet {
fn from_match(chars: &[char], start: usize, end: usize, padding: usize) -> Self {
let mut left = start;
let mut right = end;
let mut more_on_left = false;
let mut more_on_right = false;
if start > 0 {
let mut counter: usize = 0;
left -= 1;
while left > 0 {
if !chars[left].is_whitespace() {
counter += 1;
if counter == padding {
break;
}
}
left -= 1;
}
more_on_left = counter == padding && left > 0;
}
if right < chars.len() - 1 {
let mut counter: usize = 0;
right += 1;
while right < chars.len() {
if !chars[right].is_whitespace() {
counter += 1;
if counter == padding {
break;
}
}
right += 1;
}
more_on_right = counter == padding && right < chars.len();
}
Self {
left,
right,
more_on_left,
more_on_right,
}
}
}
pub struct Rebaser<'a> {
base: &'a Path,
directories: Iter<'a, String>,
}
impl<'a> Rebaser<'a> {
pub fn new(base: &'a Path, directories: &'a [String]) -> Self {
Self {
base,
directories: directories.iter(),
}
}
}
impl Iterator for Rebaser<'_> {
type Item = io::Result<PathBuf>;
fn next(&mut self) -> Option<io::Result<PathBuf>> {
let directory = self.directories.next()?;
let rebased = if directory.starts_with("/") {
PathBuf::from(directory)
} else {
self.base.join(directory)
};
Some(rebased.canonicalize())
}
}
fn determine_match_padding() -> Option<usize> {
let padding = env::var("GROX_PADDING").ok()?;
padding.parse::<usize>().ok()
}
fn get_char_index(text: &str, idx: usize) -> usize {
text[..idx].chars().count()
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
#[test]
fn find_matches_in_file() {
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "Hello world\nI am not a fool\nI'm going to fold my clothes").unwrap();
file.flush().unwrap();
let path = file.path().to_path_buf();
let pattern = Regex::new("fo.").unwrap();
let scanner = FileScanner::build(path.clone(), &pattern, 0, DEFAULT_MATCH_PADDING);
assert!(scanner.is_some());
let locations: Vec<Location> = scanner.unwrap().collect();
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].file, path);
assert_eq!(locations[0].line, 2);
println!("Match 1: {}", locations[0].text);
assert_eq!(locations[1].file, path);
assert_eq!(locations[1].line, 3);
println!("Match 2: {}", locations[1].text);
assert!(locations[1].text.contains("fold"));
}
#[test]
fn form_snippet() {
let chars: Vec<char> = "This is some very long text that says nothing".chars().collect();
let snippet = LineSnippet::from_match(&chars, 33, 36, DEFAULT_MATCH_PADDING);
assert_eq!(snippet.left, 14);
assert_eq!(snippet.right, chars.len());
assert!(snippet.more_on_left);
assert!(!snippet.more_on_right);
}
}