use std::env;
use std::error::Error;
use std::fs::remove_file;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::exit;
use clap::Parser;
use regex::Regex;
use tiny_bail::or_return_quiet;
#[derive(Parser)]
#[command(
name = "grox",
version = env!("CARGO_PKG_VERSION"),
about = "Search a file tree for regex matches."
)]
struct Args {
#[arg(help = "Search pattern.")]
pattern: String,
#[arg(short, default_value = ".", help = "Starting directory.")]
directory: String,
#[arg(short, help = "Restrict search to files whose names match this pattern.")]
file_pattern: Option<String>,
#[arg(short = 'x', action = clap::ArgAction::Append, help = "Directory excluded from the search. Can be specified multiple times.")]
excluded_dirs: Vec<String>,
#[arg(short = 'p', help = "Maximum search depth. Defaults to no maximum.")]
depth: Option<usize>,
#[arg(short = 'i', help = "Search hidden files/directories.")]
search_hidden: bool,
#[arg(short, help = "Show names of matching files only.")]
names_only: bool,
#[arg(short, help = "Open the file for the specified result.")]
line: Option<usize>,
#[arg(short, help = "Editor to use for opening files.")]
editor: Option<String>,
#[arg(short, help = "Clear the history first")]
clear: bool,
}
fn main() -> Result<(), Box<dyn Error>> {
let args = Args::parse();
if args.clear {
clear_history();
} else {
maybe_load_from_history(&args);
}
let pattern = Regex::new(&args.pattern)?;
let file_pattern = args.file_pattern.as_deref().map(Regex::new).transpose()?;
let directory = PathBuf::from(args.directory.clone());
let to_tty = atty::is(atty::Stream::Stdout);
let mut builder = grox::Builder::new(&pattern, &directory);
if let Some(ref pattern) = file_pattern {
builder.set_file_pattern(pattern);
}
for excluded_dir in &args.excluded_dirs {
builder.exclude_dir(excluded_dir);
}
if let Some(depth) = args.depth {
builder.set_depth(depth);
}
if to_tty {
builder.set_flags(grox::USE_COLOR);
}
if args.search_hidden {
builder.set_flags(grox::SEARCH_HIDDEN);
}
if args.names_only {
builder.set_flags(grox::SINGLE_MATCH);
}
Ok(process_matches(builder.build()?, &args, &directory, to_tty)?)
}
fn clear_history() {
let history_file = or_return_quiet!(history_file_path());
remove_file(&history_file).ok();
}
fn maybe_load_from_history(args: &Args) {
let line = or_return_quiet!(args.line);
let history = or_return_quiet!(get_history());
if line >= history.locations.len() {
return;
}
let directory = or_return_quiet!(PathBuf::from(&args.directory).canonicalize());
let rebaser = grox::Rebaser::new(&directory, &args.excluded_dirs);
let mut excluded_dirs = or_return_quiet!(rebaser
.map(|p| p.map(|p| p.to_string_lossy().to_string()))
.collect::<io::Result<Vec<String>>>());
excluded_dirs.sort();
if directory.to_string_lossy() != history.start
|| excluded_dirs != history.excluded_dirs
|| args.pattern != history.pattern
|| args.file_pattern != history.file_pattern
|| args.names_only != history.names_only
|| args.search_hidden != history.search_hidden
{
return;
}
let location = &history.locations[line];
open_file(&location.path, location.line, args.editor.as_deref());
}
fn process_matches(searcher: grox::Searcher, args: &Args, directory: &Path, to_tty: bool) -> io::Result<()> {
let mut history = if to_tty && args.line.is_none() {
grox::history::History::build(
directory,
args.depth,
&args.pattern,
args.file_pattern.as_deref(),
args.names_only,
args.search_hidden,
&args.excluded_dirs,
)
.ok()
} else {
None
};
let mut stdout = io::stdout();
for (count, location) in searcher.enumerate() {
let file = location.file.to_string_lossy();
if let Some(line) = args.line {
if line == count {
open_file(&file, location.line, args.editor.as_deref());
}
continue;
}
let msg = if args.names_only {
format!("({count}) {file}")
} else {
format!("({count}) {file}:{}: {}", location.line, location.text)
};
if to_tty {
println!("{msg}");
if let Some(ref mut history) = history {
history.add_location(&location.file, location.line).unwrap();
}
} else if print_message(&mut stdout, msg).is_err() {
return Ok(());
}
}
if let Some(history) = history {
return save_history(history);
}
Ok(())
}
fn get_history() -> Option<grox::history::History> {
let history_file = history_file_path()?;
grox::history::History::from_file(&history_file).ok()
}
fn save_history(history: grox::history::History) -> io::Result<()> {
if let Some(history_file) = history_file_path() {
history.save(&history_file)
} else {
Ok(())
}
}
fn history_file_path() -> Option<PathBuf> {
let home_dir = home::home_dir()?;
Some(home_dir.join(".grox_history.json"))
}
fn print_message(stdout: &mut io::Stdout, msg: String) -> io::Result<()> {
stdout.write_all(msg.as_bytes())?;
stdout.write_all("\n".as_bytes())?;
stdout.flush()?;
Ok(())
}
fn open_file(file: &str, line: usize, specified_editor: Option<&str>) -> ! {
let openers: [Box<dyn grox::open::Opener>; 7] = [
Box::new(grox::open::LessOpener),
Box::new(grox::open::ViOpener),
Box::new(grox::open::EmacsOpener),
Box::new(grox::open::CodeOpener),
Box::new(grox::open::XedOpener),
Box::new(grox::open::SublimeOpener),
Box::new(grox::open::DefaultOpener),
];
let editor = if let Some(e) = specified_editor {
e.to_string()
} else if let Ok(e) = env::var("EDITOR") {
e
} else {
"less".to_string()
};
for opener in openers {
if opener.handles_editor(&editor) {
grox::open::open(opener.as_ref(), editor, file, line);
eprintln!("execvp failed: {}", io::Error::last_os_error());
break;
}
}
exit(1);
}