use clap::{ArgAction, CommandFactory as _, Parser, ValueHint, value_parser};
use clap_complete::aot::{Shell, generate};
use core::num::NonZeroUsize;
use fdf::filters::{FileTypeFilterParser, SizeFilterParser, TimeFilterParser};
use fdf::walk::Finder;
use fdf::{
SearchConfigError, TraversalError,
filters::{FileTypeFilter, SizeFilter, TimeFilter},
};
use std::env;
use std::ffi::OsString;
use std::io::{self, stdout};
use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
use std::process::Command;
#[cfg(all(
any(target_os = "linux", target_os = "android", target_os = "macos"),
not(miri),
not(debug_assertions),
feature = "mimalloc",
))]
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Parser)]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[allow(clippy::struct_excessive_bools)]
struct Args {
#[arg(value_name = "PATTERN", help = "Pattern to search for", index = 1)]
pattern: Option<String>,
#[arg(
value_name = "PATH",
help = format!("Path to search (defaults to current working directory)"),
value_hint=ValueHint::DirPath,
required=false,
index=2
)]
directory: Option<OsString>,
#[arg(
short = 'H',
long = "hidden",
help = "Shows hidden files eg .gitignore or .bashrc, defaults to off"
)]
hidden: bool,
#[arg(
short = 'S',
long = "sort",
help = "Sort the entries alphabetically (this has quite the performance cost)",
default_value_t = false
)]
sort: bool,
#[arg(
short = 's',
long = "case-sensitive",
default_value_t = true,
help = "Enable case-sensitive matching, defaults to false"
)]
case_insensitive: bool,
#[arg(
short = 'e',
long = "extension",
help = "filters based on extension, eg --extension .txt or -E txt",
long_help = "An example command would be `fdf -HI -e c '^str' / "
)]
extension: Option<String>,
#[arg(
short = 'j',
long = "threads",
help = "Number of threads to use, defaults to available threads available on your computer"
)]
thread_num: Option<NonZeroUsize>,
#[arg(
short = 'a',
long = "absolute-path",
help = "Starts with the directory entered being resolved to full"
)]
absolute_path: bool,
#[arg(
short = 'L',
long = "follow",
default_value_t = false,
help = "Include symlinks in traversal,defaults to false"
)]
follow_symlinks: bool,
#[arg(
long = "nocolour",
alias = "nocolor",
default_value_t = false,
help = "Disable colouring output when sending to terminal"
)]
no_colour: bool,
#[arg(
short = 'g',
long = "glob",
required = false,
default_value_t = false,
help = "Use a glob pattern,defaults to off"
)]
glob: bool,
#[arg(
short = 'n',
long = "max-results",
help = "Retrieves the first eg 10 results, 'fdf -n 10 '.cache' /"
)]
top_n: Option<usize>,
#[arg(
short = 'd',
long = "depth",
alias = "max-depth",
help = "Retrieves only traverse to x depth"
)]
depth: Option<u32>,
#[arg(
short = 'p',
long = "full-path",
required = false,
default_value_t = false,
help = "Use a full path for regex matching, default to false"
)]
full_path: bool,
#[arg(
short = 'F',
long = "fixed-strings",
required = false,
default_value_t = false,
help = "Use a fixed string not a regex, defaults to false",
conflicts_with = "glob"
)]
fixed_string: bool,
#[arg(
long = "show-errors",
required = false,
default_value_t = false,
help = "Show errors when traversing"
)]
show_errors: bool,
#[arg(
long = "same-file-system",
alias="one-file-system", //alias for fd for easier use
required = false,
default_value_t = false,
help = "Only traverse the same filesystem as the starting directory"
)]
same_file_system: bool,
#[arg(
short = '0',
long = "print0",
alias = "null-terminated",
required = false,
default_value_t = false,
help = "Makes all output null terminated",
long_help = "Makes all output null terminated as opposed to newline terminated only applies to non-coloured output and redirected(useful for xargs)"
)]
print0: bool,
#[arg(
short = 'I',
long = "no-ignore",
default_value_t = false,
help = "Do not respect .gitignore rules during traversal"
)]
no_ignore: bool,
#[arg(
long = "strip-cwd-prefix",
default_value_t = false,
help = "Strip the leading './' from results when searching the current directory"
)]
strip_cwd_prefix: bool,
#[arg(
short = 'Q',
long = "quoted",
default_value_t = false,
help = "Wrap printed file paths in double quotes"
)]
quoted: bool,
#[arg(
long = "exec",
value_name = "CMD",
num_args = 1..,
allow_hyphen_values = true,
conflicts_with_all = ["generate", "quoted", "print0", "no_colour"],
help = "Execute a command once per search result",
long_help = "Execute a command once per search result. Use '{}' to insert the matched path into an argument; if '{}' is omitted, the path is appended as the final argument. This option should be the final CLI flag
Example: 'fdf 'junk.files' 'test_directory' -HI --exec rm -rf ' , delete all files meeting the criteria"
)]
exec: Option<Vec<OsString>>,
#[arg(
long = "ignore",
value_name = "PATTERN",
action = ArgAction::Append,
help = "Ignore paths that match this regex pattern (repeatable)"
)]
ignore: Vec<String>,
#[arg(
long = "ignoreg",
value_name = "GLOB",
action = ArgAction::Append,
help = "Ignore paths that match this glob pattern (repeatable)"
)]
ignoreg: Vec<String>,
#[arg(
long = "ignore-file",
value_name = "path",
action = ArgAction::Append,
value_hint = ValueHint::FilePath,
help = "Add a custom ignore-file in '.gitignore' format. These files have a low precedence."
)]
ignore_file: Vec<OsString>,
#[arg(
long = "and",
value_name = "pattern",
action = ArgAction::Append,
help = "Add additional required search patterns, all of which must be matched. Multiple additional patterns can be specified. The patterns are regular expressions, unless '--glob' or '--fixed-strings' is used."
)]
r#and: Vec<String>,
#[arg(
long = "size",
allow_hyphen_values = true,
value_name = "SIZE",
value_parser = SizeFilterParser,
help = "Filter by file size (supports custom sizes with +/- prefixes)",
verbatim_doc_comment
)]
size: Option<SizeFilter>,
#[arg(
long = "time-modified",
short = 'T',
allow_hyphen_values = true,
value_name = "TIME",
value_parser = TimeFilterParser,
help = "Filter by file modification time (supports relative times with +/- prefixes)",
verbatim_doc_comment
)]
time: Option<TimeFilter>,
#[arg(
short = 't',
long = "type",
required = false,
value_parser = FileTypeFilterParser,
help = "Filter by file type",
)]
type_of: Option<FileTypeFilter>,
#[arg(
long = "generate",
action = ArgAction::Set,
value_parser = value_parser!(Shell),
help = "Generate shell completions",
long_help = "
Generate shell completions for bash/zsh/fish/powershell
To use: eval \"$(fdf --generate SHELL)\"
Example:
# Add to shell config for permanent use
echo 'eval \"$(fdf --generate zsh)\"' >> ~/.zshrc && source ~/.zshrc "
)]
generate: Option<Shell>,
}
fn main() -> Result<(), SearchConfigError> {
let args = Args::parse();
if let Some(generator) = args.generate {
let mut cmd = Args::command();
let bin_name = cmd.get_name().to_owned();
cmd.set_bin_name("fdf");
generate(generator, &mut cmd, bin_name, &mut stdout());
return Ok(());
}
let path: OsString = args.directory.unwrap_or_else(|| ".".into());
let root_is_cwd = matches!(path.as_bytes(), b"." | b"./");
let strip_cwd_prefix = args.strip_cwd_prefix && root_is_cwd;
let finder = Finder::init(&path)
.pattern(args.pattern.unwrap_or_else(String::new)) .and_patterns(args.r#and)
.keep_hidden(!args.hidden)
.case_insensitive(args.case_insensitive)
.fixed_string(args.fixed_string)
.canonicalise_root(args.absolute_path)
.file_name_only(!args.full_path)
.extension(args.extension.unwrap_or_else(String::new))
.max_depth(args.depth)
.follow_symlinks(args.follow_symlinks)
.filter_by_size(args.size)
.filter_by_time(args.time)
.type_filter(args.type_of)
.collect_errors(args.show_errors)
.use_glob(args.glob)
.same_filesystem(args.same_file_system)
.respect_gitignore(!args.no_ignore)
.ignore_patterns(args.ignore)
.ignore_glob_patterns(args.ignoreg)
.ignore_files(args.ignore_file)
.thread_count(args.thread_num)
.build()?;
let errors = finder.error_store();
if let Some(exec) = args.exec.as_deref() {
run_exec_search(
finder.traverse()?,
exec,
args.sort,
args.top_n,
strip_cwd_prefix,
)?;
if args.show_errors {
print_collected_errors(errors.as_deref());
}
return Ok(());
}
finder
.build_printer()?
.limit(args.top_n)
.sort(args.sort)
.null_terminated(args.print0)
.nocolour(args.no_colour)
.quoted(args.quoted)
.strip_leading_dot_slash(strip_cwd_prefix)
.print_errors(args.show_errors)
.print()?;
Ok(())
}
#[allow(clippy::print_stderr)] fn print_collected_errors(errors: Option<&std::sync::Mutex<Vec<TraversalError>>>) {
if let Some(errors_arc) = errors
&& let Ok(error_vec) = errors_arc.lock()
{
for error in error_vec.iter() {
eprintln!("{error}");
}
}
}
fn run_exec_search<I>(
paths: I,
exec: &[OsString],
sort: bool,
limit: Option<usize>,
strip_leading_dot_slash: bool,
) -> Result<(), SearchConfigError>
where
I: Iterator<Item = fdf::fs::DirEntry>,
{
let new_limit = limit.unwrap_or(usize::MAX);
if sort {
let mut collected: Vec<_> = paths.collect();
collected.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
for path in collected.into_iter().take(new_limit) {
execute_for_path(exec, &path, strip_leading_dot_slash)?;
}
} else {
for path in paths.take(new_limit) {
execute_for_path(exec, &path, strip_leading_dot_slash)?;
}
}
Ok(())
}
#[allow(clippy::indexing_slicing)]
fn execute_for_path(
exec: &[OsString],
path: &fdf::fs::DirEntry,
strip_leading_dot_slash: bool,
) -> Result<(), SearchConfigError> {
let argv = build_exec_argv(exec, displayed_path_bytes(path, strip_leading_dot_slash));
let status = Command::new(&argv[0]).args(&argv[1..]).status()?;
if status.success() {
return Ok(());
}
let command_name = argv[0].as_os_str().to_string_lossy();
Err(SearchConfigError::IOError(io::Error::other(format!(
"command '{command_name}' exited with status {status}"
))))
}
fn displayed_path_bytes(path: &fdf::fs::DirEntry, strip_leading_dot_slash: bool) -> &[u8] {
let start = usize::from(strip_leading_dot_slash) * 2;
unsafe { path.get_unchecked(start..) }
}
fn build_exec_argv(exec: &[OsString], path: &[u8]) -> Vec<OsString> {
let mut argv = Vec::with_capacity(exec.len() + 1);
let mut replaced_placeholder = false;
for arg in exec {
let (replaced, did_replace) = replace_exec_placeholder(arg, path);
replaced_placeholder |= did_replace;
argv.push(replaced);
}
if !replaced_placeholder {
argv.push(OsString::from_vec(path.to_vec()));
}
argv
}
#[allow(clippy::indexing_slicing)]
fn replace_exec_placeholder(arg: &OsString, path: &[u8]) -> (OsString, bool) {
let bytes = arg.as_os_str().as_bytes();
let mut index = 0;
let mut replaced = false;
let mut output = Vec::with_capacity(bytes.len().saturating_add(path.len()));
while let Some(relative_pos) = bytes[index..].windows(2).position(|window| window == b"{}") {
let absolute_pos = index + relative_pos;
output.extend_from_slice(&bytes[index..absolute_pos]);
output.extend_from_slice(path);
index = absolute_pos + 2;
replaced = true;
}
if !replaced {
return (arg.clone(), false);
}
output.extend_from_slice(&bytes[index..]);
(OsString::from_vec(output), true)
}