#![warn(missing_docs)]
use clap::Parser;
use colored::Colorize;
use ignore::Walk;
use regex::Regex;
use serde::Serialize;
use std::error::Error;
use std::fs;
use std::io::{self, BufRead, Seek};
use std::num::NonZero;
use std::sync::{mpsc, Arc};
use std::time;
use strum_macros::Display;
use strum_macros::EnumString;
mod file_type;
mod query_regex;
mod threads;
#[derive(Parser, Debug, Default)]
#[command(
version,
arg_required_else_help = true,
about = "Quick search for symbol definitions in various programming languages",
long_about = "Quick search for symbol definitions in various programming languages"
)]
pub struct Args {
pub query: String,
pub file_path: Option<Vec<String>>,
#[arg(short = 't', long = "type")]
pub file_type: Option<String>,
#[arg(short = 'n', long = "line-number")]
pub line_number: bool,
#[arg(long = "color")]
pub color: Option<String>,
#[arg(long = "no-color")]
pub no_color: bool,
#[arg(short = 'l', long = "limit")]
pub limit: Option<usize>,
#[arg(long = "debug")]
pub debug: bool,
#[arg(long = "search-method")]
pub search_method: Option<SearchMethod>,
#[arg(short = 'j', long = "threads")]
pub threads: Option<NonZero<usize>>,
#[arg(long = "format")]
pub format: Option<SearchResultFormat>,
}
impl Args {
pub fn from_query(query: &str) -> Args {
Args {
query: query.into(),
..Args::default()
}
}
pub fn new(
query: String,
file_type: Option<String>,
file_path: Option<Vec<String>>,
line_number: bool,
) -> Args {
Args {
query,
file_type,
file_path,
line_number,
..Args::default()
}
}
}
#[derive(clap::ValueEnum, Clone, Default, Debug, EnumString, PartialEq, Display)]
pub enum SearchMethod {
#[default]
PrescanRegex,
PrescanMemmem,
NoPrescan,
}
#[derive(Clone, Debug)]
struct Config {
query: String,
file_paths: Vec<String>,
file_type: FileType,
line_number: bool,
debug: bool,
limit: Option<usize>,
no_color: bool,
color: ColorOption,
search_method: SearchMethod,
num_threads: NonZero<usize>,
format: SearchResultFormat,
}
impl Config {
pub fn new(args: Args) -> Result<Config, String> {
if args.debug {
let args_formatted = format!("Creating config with args {:?}", args);
println!("{}", args_formatted.yellow());
}
let file_paths = match args.file_path {
Some(file_path) => file_path,
None => vec![".".into()],
};
let file_type = match args.file_type {
Some(file_type_string) => FileType::from_string(file_type_string.as_str())?,
None => FileType::from_file_paths(&file_paths)?,
};
let color = match args.color {
Some(color_option_string) => ColorOption::from_string(color_option_string.as_str())?,
None => ColorOption::AUTO,
};
let num_threads = match args.threads {
Some(threads) => threads,
None => NonZero::new(5).expect("Default number of threads was invalid"),
};
let config = Config {
query: args.query,
file_paths,
file_type,
line_number: args.line_number,
debug: args.debug,
no_color: args.no_color,
color,
search_method: args.search_method.unwrap_or_default(),
limit: args.limit,
num_threads,
format: args.format.unwrap_or_default(),
};
debug(&config, format!("Created config {:?}", config).as_str());
Ok(config)
}
}
#[derive(Clone, Debug)]
pub enum FileType {
JS,
PHP,
RS,
PY,
RB,
}
impl FileType {
pub fn from_string(file_type_string: &str) -> Result<FileType, String> {
match file_type_string {
"js" => Ok(FileType::JS),
"ts" => Ok(FileType::JS),
"jsx" => Ok(FileType::JS),
"tsx" => Ok(FileType::JS),
"javascript" => Ok(FileType::JS),
"javascript.jsx" => Ok(FileType::JS),
"javascriptreact" => Ok(FileType::JS),
"typescript" => Ok(FileType::JS),
"typescript.tsx" => Ok(FileType::JS),
"typescriptreact" => Ok(FileType::JS),
"php" => Ok(FileType::PHP),
"rs" => Ok(FileType::RS),
"rust" => Ok(FileType::RS),
"py" => Ok(FileType::PY),
"python" => Ok(FileType::PY),
"rb" => Ok(FileType::RB),
"ruby" => Ok(FileType::RB),
_ => Err(format!("Invalid file type '{}'", file_type_string)),
}
}
pub fn to_string(&self) -> String {
match self {
Self::JS => String::from("js"),
Self::PHP => String::from("php"),
Self::RS => String::from("rs"),
Self::PY => String::from("py"),
Self::RB => String::from("rb"),
}
}
pub fn from_file_paths(file_paths: &Vec<String>) -> Result<FileType, &'static str> {
for file_path in file_paths {
let guess = file_type::guess_file_type_from_file_path(file_path);
if let Some(value) = guess {
return Ok(value);
}
}
Err("Unable to guess file type from file paths")
}
}
#[derive(Clone, Debug)]
pub enum ColorOption {
ALWAYS,
NEVER,
AUTO,
}
impl ColorOption {
pub fn from_string(color_option_string: &str) -> Result<ColorOption, String> {
match color_option_string {
"always" => Ok(ColorOption::ALWAYS),
"never" => Ok(ColorOption::NEVER),
"auto" => Ok(ColorOption::AUTO),
_ => Err(format!("Invalid color option '{}'", color_option_string)),
}
}
}
#[derive(clap::ValueEnum, Clone, Default, Debug, EnumString, PartialEq, Display, Copy)]
pub enum SearchResultFormat {
#[default]
Grep,
JsonPerMatch,
JsonList,
}
#[derive(Debug, PartialEq, Clone, Serialize)]
pub enum SearchEventType {
START,
END,
MATCH,
NONE,
}
impl SearchEventType {
fn is_empty(&self) -> bool {
match self {
SearchEventType::NONE => true,
_ => false,
}
}
}
#[derive(serde::Serialize)]
struct SearchEventResult {
pub event_type: SearchEventType,
}
impl SearchEventResult {
pub fn to_json_in_list(&self) -> String {
match self.event_type {
SearchEventType::START => serde_json::to_string(self).unwrap_or_default() + ",",
SearchEventType::END => serde_json::to_string(self).unwrap_or_default(),
SearchEventType::MATCH => serde_json::to_string(self).unwrap_or_default() + ",",
SearchEventType::NONE => String::from(""),
}
}
}
#[derive(Debug, PartialEq, Clone, Serialize)]
pub struct SearchResult {
#[serde(skip_serializing_if = "SearchEventType::is_empty")]
pub event_type: SearchEventType,
pub file_path: String,
pub line_number: Option<usize>,
pub text: String,
}
impl SearchResult {
pub fn to_grep(&self) -> String {
match self.line_number {
Some(line_number) => format!(
"{}:{}:{}",
self.file_path.magenta(),
line_number.to_string().green(),
self.text
),
None => format!("{}:{}", self.file_path.magenta(), self.text),
}
}
pub fn to_json_per_match(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn to_json_in_list(&self) -> String {
serde_json::to_string(self).unwrap_or_default() + ","
}
}
pub struct Searcher {
config: Config,
}
impl Searcher {
pub fn new(args: Args) -> Result<Searcher, String> {
let config = Config::new(args)?;
Ok(Searcher { config })
}
pub fn search_and_format(&self) -> Result<Vec<String>, Box<dyn Error>> {
let mut results: Vec<String> = vec![];
let error = self.search_and_format_callback(|result| results.push(result));
match error {
Ok(_) => Ok(results),
Err(err) => Err(err),
}
}
pub fn search_and_format_callback<F>(&self, mut callback: F) -> Result<(), Box<dyn Error>>
where
F: FnMut(String),
{
if self.config.format == SearchResultFormat::JsonList {
callback(String::from("["));
let event = SearchEventResult {
event_type: SearchEventType::START,
};
callback(event.to_json_in_list());
}
let error = self.search_callback(|result| match self.config.format {
SearchResultFormat::Grep => callback(result.to_grep()),
SearchResultFormat::JsonPerMatch => callback(result.to_json_per_match()),
SearchResultFormat::JsonList => callback(result.to_json_in_list()),
});
if self.config.format == SearchResultFormat::JsonList {
let event = SearchEventResult {
event_type: SearchEventType::END,
};
callback(event.to_json_in_list());
callback(String::from("]"));
}
error
}
pub fn search_callback<F>(&self, mut callback: F) -> Result<(), Box<dyn Error>>
where
F: FnMut(SearchResult),
{
let start: Option<time::Instant> = if self.config.debug {
Some(time::Instant::now())
} else {
None
};
let re = query_regex::get_regex_for_query(&self.config.query, &self.config.file_type);
let config = Arc::new(self.config.clone());
let mut pool = threads::ThreadPool::new(config.num_threads, config.debug);
match config.color {
ColorOption::ALWAYS => colored::control::set_override(true),
ColorOption::NEVER => colored::control::set_override(false),
ColorOption::AUTO => (),
}
if config.no_color {
colored::control::set_override(false);
}
self.debug("Starting searchers");
let mut searched_file_count = 0;
let rx = {
let (tx, rx) = mpsc::channel();
for file_path in &config.file_paths {
for entry in Walk::new(file_path) {
let path = match entry {
Ok(path) => path.into_path(),
Err(err) => {
return Err(Box::new(err));
}
};
if path.is_dir() {
continue;
}
let path = match path.to_str() {
Some(p) => p.to_string(),
None => {
return Err(Box::from("Error getting string from path"));
}
};
if !file_type::path_matches_file_type(&path, &config.file_type) {
continue;
}
searched_file_count += 1;
let re1 = re.clone();
let path1 = path.clone();
let config1 = Arc::clone(&config);
let tx1 = tx.clone();
pool.execute(move || {
search_file(
&re1,
&path1,
&config1,
move |file_results: Vec<SearchResult>| {
let _ = tx1.send(file_results);
},
);
})
}
}
rx
};
self.debug("Listening to searcher results");
let mut result_counter: usize = 0;
'all_results: for received_results in rx {
for received_result in received_results {
result_counter += 1;
callback(received_result);
if let (true, Some(start)) = (self.config.debug, start) {
self.debug(
format!("Found a result in {} ms", start.elapsed().as_millis()).as_str(),
);
}
if let Some(i) = self.config.limit {
self.debug(format!("This is result {}; limit {}", result_counter, i).as_str());
if result_counter >= i {
self.debug("Limit reached");
pool.stop();
break 'all_results;
}
}
}
}
self.debug("Waiting for searchers to complete");
pool.wait_for_all_jobs_and_stop();
self.debug("Searchers complete");
if let (true, Some(start)) = (self.config.debug, start) {
self.debug(
format!(
"Scanned {} files in {} ms",
searched_file_count,
start.elapsed().as_millis()
)
.as_str(),
);
}
Ok(())
}
pub fn search(&self) -> Result<Vec<SearchResult>, Box<dyn Error>> {
let mut results: Vec<SearchResult> = vec![];
let search_result = self.search_callback(|result| results.push(result));
match search_result {
Ok(_) => Ok(results),
Err(err) => Err(err),
}
}
fn debug(&self, output: &str) {
if self.config.debug {
println!("{}", output.yellow());
}
}
}
fn debug(config: &Config, output: &str) {
if config.debug {
println!("{}", output.yellow());
}
}
fn search_file<F>(re: &Regex, file_path: &str, config: &Config, callback: F)
where
F: FnOnce(Vec<SearchResult>) + Send + 'static,
{
debug(config, format!("Scanning file {}", file_path).as_str());
let file = fs::File::open(file_path);
match file {
Ok(mut file) => {
if match config.search_method {
SearchMethod::PrescanRegex => !file_type::does_file_match_regexp(&file, re),
SearchMethod::PrescanMemmem => {
!file_type::does_file_match_query(&file, &config.query)
}
SearchMethod::NoPrescan => false,
} {
debug(
config,
format!("Presearch of {} found no match; skipping", &file_path).as_str(),
);
callback(vec![]);
return;
}
let rewind_result = file.rewind();
if rewind_result.is_err() {
callback(vec![]);
return;
}
debug(
config,
format!(
"Presearch of {} was successful; searching for line",
&file_path
)
.as_str(),
);
callback(search_file_line_by_line(re, file_path, &file, config));
}
Err(_) => {
callback(vec![]);
}
}
}
fn search_file_line_by_line(
re: &Regex,
file_path: &str,
file: &fs::File,
config: &Config,
) -> Vec<SearchResult> {
let lines = io::BufReader::new(file).lines();
let mut line_counter = 0;
lines
.filter_map(|line| {
line_counter += 1;
if !match &line {
Ok(line) => re.is_match(line),
Err(_) => false,
} {
return None;
}
let text = match line {
Ok(line) => line,
Err(_err) => String::from(""),
};
Some(SearchResult {
event_type: match config.format {
SearchResultFormat::JsonList => SearchEventType::MATCH,
_ => SearchEventType::NONE,
},
file_path: String::from(file_path),
line_number: if config.line_number {
Some(line_counter)
} else {
None
},
text: text.trim().into(),
})
})
.collect()
}