diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec8dfc3a..7b6390443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ was often useful, it also broke some existing workflows, and there wasn't a good way to opt out of it. And there isn't really a good way for us to add a way to opt out of it. And you can easily get similar behavior by adding `.git/` to your global fdignore file. See #1457. +- Add `-f` \ `--filter ` argument that allows you to filter results based on the output of a command, as requested in #400. ## Bugfixes diff --git a/src/cli.rs b/src/cli.rs index 30f02f426..df955d9dd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -776,6 +776,11 @@ impl clap::FromArgMatches for Exec { .get_occurrences::("exec_batch") .map(CommandSet::new_batch) }) + .or_else(|| { + matches + .get_occurrences::("filter") + .map(CommandSet::new_filter) + }) .transpose() .map_err(|e| clap::Error::raw(ErrorKind::InvalidValue, e))?; Ok(Exec { command }) @@ -797,7 +802,7 @@ impl clap::Args for Exec { .allow_hyphen_values(true) .value_terminator(";") .value_name("cmd") - .conflicts_with("list_details") + .conflicts_with_all(["list_details", "exec_batch"]) .help("Execute a command for each search result") .long_help( "Execute a command for each search result in parallel (use --threads=1 for sequential command execution). \ @@ -851,7 +856,35 @@ impl clap::Args for Exec { fd -g 'test_*.py' -X vim\n\n \ - Find all *.rs files and count the lines with \"wc -l ...\":\n\n \ fd -e rs -X wc -l\ - " + " + ), + ) + .arg( + Arg::new("filter") + .action(ArgAction::Append) + .long("filter") + .short('f') + .num_args(1..) + .allow_hyphen_values(true) + .value_terminator(";") + .value_name("cmd") + .conflicts_with_all(["exec", "exec_batch", "list_details"]) + .help("Execute a command to determine whether each result should be filtered") + .long_help( + "Execute a command in parallel for each search result, filtering out results where the exit code is non-zero. \ + There is no guarantee of the order commands are executed in, and the order should not be depended upon. \ + All positional arguments following --filter are considered to be arguments to the command - not to fd. \ + It is therefore recommended to place the '-f'/'--filter' option last.\n\ + The following placeholders are substituted before the command is executed:\n \ + '{}': path (of the current search result)\n \ + '{/}': basename\n \ + '{//}': parent directory\n \ + '{.}': path without file extension\n \ + '{/.}': basename without file extension\n \ + '{{': literal '{' (for escaping)\n \ + '}}': literal '}' (for escaping)\n\n\ + If no placeholder is present, an implicit \"{}\" at the end is assumed.\n\n\ + " ), ) } diff --git a/src/exec/command.rs b/src/exec/command.rs index ff13f9121..bc173a32a 100644 --- a/src/exec/command.rs +++ b/src/exec/command.rs @@ -11,6 +11,8 @@ struct Outputs { stdout: Vec, stderr: Vec, } + +/// Used to print the results of commands that run on results in a thread-safe way struct OutputBuffer<'a> { output_permission: &'a Mutex<()>, outputs: Vec, @@ -67,8 +69,9 @@ pub fn execute_commands>>( let output = if enable_output_buffering { cmd.output() } else { - // If running on only one thread, don't buffer output - // Allows for viewing and interacting with intermediate command output + // If running on only one thread, don't buffer output; instead just + // write directly to stdout. Allows for viewing and interacting with + // intermediate command output cmd.spawn().and_then(|c| c.wait_with_output()) }; @@ -78,7 +81,7 @@ pub fn execute_commands>>( if enable_output_buffering { output_buffer.push(output.stdout, output.stderr); } - if output.status.code() != Some(0) { + if !output.status.success() { output_buffer.write(); return ExitCode::GeneralError; } @@ -93,6 +96,59 @@ pub fn execute_commands>>( ExitCode::Success } +/// Executes a command and pushes the path to the buffer if it succeeded with a +/// non-zero exit code. +pub fn execute_commands_filtering>>( + path: &std::path::Path, + cmds: I, + out_perm: &Mutex<()>, + enable_output_buffering: bool, +) -> ExitCode { + let mut output_buffer = OutputBuffer::new(out_perm); + + // Convert path to bufferable path string + let path_str = match path.to_str() { + Some(path) => format!("{}\n", path), + None => { + // Probably had non UTF-8 chars in the path somehow + return ExitCode::GeneralError; + } + }; + let path_u8 = path_str.as_bytes().to_vec(); + + for result in cmds { + let mut cmd = match result { + Ok(cmd) => cmd, + Err(e) => return handle_cmd_error(None, e), + }; + + // Spawn the supplied command. + let output = cmd.output(); + + match output { + Ok(output) => { + if output.status.success() { + if enable_output_buffering { + // Push nothing to stderr because, well, there's nothing to push. + output_buffer.push(path_u8.clone(), vec![]); + } else { + print!("{}", path_str); + } + } else { + return ExitCode::GeneralError; + } + } + Err(why) => { + return handle_cmd_error(Some(&cmd), why); + } + } + } + output_buffer.write(); + ExitCode::Success +} + +/// Displays user-friendly error message based on the kind of error that occurred while +/// running a command pub fn handle_cmd_error(cmd: Option<&Command>, err: io::Error) -> ExitCode { match (cmd, err) { (Some(cmd), err) if err.kind() == io::ErrorKind::NotFound => { diff --git a/src/exec/job.rs b/src/exec/job.rs index 4864d6dca..9ed35b317 100644 --- a/src/exec/job.rs +++ b/src/exec/job.rs @@ -15,6 +15,7 @@ pub fn job( cmd: &CommandSet, out_perm: &Mutex<()>, config: &Config, + filter: bool, ) -> ExitCode { // Output should be buffered when only running a single thread let buffer_output: bool = config.threads > 1; @@ -39,7 +40,9 @@ pub fn job( config.path_separator.as_deref(), out_perm, buffer_output, + filter, ); + ret = merge_exitcodes([ret, code]); } // Returns error in case of any error. diff --git a/src/exec/mod.rs b/src/exec/mod.rs index d95f5d9a8..8c74b948f 100644 --- a/src/exec/mod.rs +++ b/src/exec/mod.rs @@ -16,7 +16,7 @@ use argmax::Command; use crate::exit_codes::{merge_exitcodes, ExitCode}; -use self::command::{execute_commands, handle_cmd_error}; +use self::command::{execute_commands, execute_commands_filtering, handle_cmd_error}; use self::input::{basename, dirname, remove_extension}; pub use self::job::{batch, job}; use self::token::{tokenize, Token}; @@ -28,6 +28,8 @@ pub enum ExecutionMode { OneByOne, /// Command is run for a batch of results at once Batch, + /// Command is executed for each search result to determine if it should be filtered + FilterResults, } #[derive(Debug, Clone, PartialEq)] @@ -76,6 +78,25 @@ impl CommandSet { }) } + pub fn new_filter(input: I) -> Result + where + I: IntoIterator, + T: IntoIterator, + S: AsRef, + { + Ok(CommandSet { + mode: ExecutionMode::FilterResults, + commands: input + .into_iter() + .map(CommandTemplate::new) + .collect::>()?, + }) + } + + pub fn get_mode(&self) -> ExecutionMode { + self.mode + } + pub fn in_batch_mode(&self) -> bool { self.mode == ExecutionMode::Batch } @@ -86,12 +107,18 @@ impl CommandSet { path_separator: Option<&str>, out_perm: &Mutex<()>, buffer_output: bool, + filter: bool, ) -> ExitCode { let commands = self .commands .iter() .map(|c| c.generate(input, path_separator)); - execute_commands(commands, out_perm, buffer_output) + + if filter { + execute_commands_filtering(input, commands, out_perm, buffer_output) + } else { + execute_commands(commands, out_perm, buffer_output) + } } pub fn execute_batch(&self, paths: I, limit: usize, path_separator: Option<&str>) -> ExitCode diff --git a/src/exit_codes.rs b/src/exit_codes.rs index c87d8c273..ffc8dceb7 100644 --- a/src/exit_codes.rs +++ b/src/exit_codes.rs @@ -43,6 +43,7 @@ impl ExitCode { } } +/// If any of the exit codes was an error, this returns a GeneralError pub fn merge_exitcodes(results: impl IntoIterator) -> ExitCode { if results.into_iter().any(ExitCode::is_error) { return ExitCode::GeneralError; diff --git a/src/walk.rs b/src/walk.rs index 155d329c2..d4b50de2a 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -408,28 +408,31 @@ impl WorkerState { // This will be set to `Some` if the `--exec` argument was supplied. if let Some(ref cmd) = config.command { - if cmd.in_batch_mode() { - exec::batch(rx.into_iter().flatten(), cmd, config) - } else { - let out_perm = Mutex::new(()); - - thread::scope(|scope| { - // Each spawned job will store its thread handle in here. - let threads = config.threads; - let mut handles = Vec::with_capacity(threads); - for _ in 0..threads { - let rx = rx.clone(); - - // Spawn a job thread that will listen for and execute inputs. - let handle = scope - .spawn(|| exec::job(rx.into_iter().flatten(), cmd, &out_perm, config)); - - // Push the handle of the spawned thread into the vector for later joining. - handles.push(handle); - } - let exit_codes = handles.into_iter().map(|handle| handle.join().unwrap()); - merge_exitcodes(exit_codes) - }) + match cmd.get_mode() { + exec::ExecutionMode::Batch => exec::batch(rx.into_iter().flatten(), cmd, config), + exec::ExecutionMode::OneByOne | exec::ExecutionMode::FilterResults => { + let out_perm = Mutex::new(()); + let filter = cmd.get_mode() == exec::ExecutionMode::FilterResults; + + thread::scope(|scope| { + // Each spawned job will store its thread handle in here. + let threads = config.threads; + let mut handles = Vec::with_capacity(threads); + for _ in 0..threads { + let rx = rx.clone(); + + // Spawn a job thread that will listen for and execute inputs. + let handle = scope.spawn(|| { + exec::job(rx.into_iter().flatten(), cmd, &out_perm, config, filter) + }); + + // Push the handle of the spawned thread into the vector for later joining. + handles.push(handle); + } + let exit_codes = handles.into_iter().map(|handle| handle.join().unwrap()); + merge_exitcodes(exit_codes) + }) + } } } else { let stdout = io::stdout().lock();