From 4e7b403c1fea376f1b5be8cb25e10688a4b120f5 Mon Sep 17 00:00:00 2001 From: Thayne McCombs Date: Fri, 17 Jun 2022 02:08:24 -0600 Subject: [PATCH] Use clap-derive for option parsing This makes the definition of arguments to fd a little more ergonomic, and makes it easier to insure the types for the arguments are consitent. --- .github/workflows/CICD.yml | 17 +- .gitignore | 1 + Cargo.lock | 20 + Cargo.toml | 8 +- Makefile | 37 ++ README.md | 4 +- build.rs | 21 - src/app.rs | 774 ------------------------------------- src/cli.rs | 747 +++++++++++++++++++++++++++++++++++ src/exec/job.rs | 11 +- src/exec/mod.rs | 45 +-- src/filter/size.rs | 10 +- src/main.rs | 356 ++++++----------- src/walk.rs | 15 +- tests/tests.rs | 16 +- 15 files changed, 999 insertions(+), 1083 deletions(-) create mode 100644 Makefile delete mode 100644 src/app.rs create mode 100644 src/cli.rs diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 6ba1509..c2e76be 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -181,6 +181,11 @@ jobs: command: test args: --locked --target=${{ matrix.job.target }} ${{ steps.test-options.outputs.CARGO_TEST_OPTIONS}} + - name: Generate completions + id: completions + shell: bash + run: make completions + - name: Create tarball id: package shell: bash @@ -193,7 +198,6 @@ jobs: PKG_STAGING="${{ env.CICD_INTERMEDIATES_DIR }}/package" ARCHIVE_DIR="${PKG_STAGING}/${PKG_BASENAME}/" mkdir -p "${ARCHIVE_DIR}" - mkdir -p "${ARCHIVE_DIR}/autocomplete" # Binary cp "${{ steps.strip.outputs.BIN_PATH }}" "$ARCHIVE_DIR" @@ -205,10 +209,7 @@ jobs: cp "README.md" "LICENSE-MIT" "LICENSE-APACHE" "CHANGELOG.md" "$ARCHIVE_DIR" # Autocompletion files - cp 'target/${{ matrix.job.target }}/release/build/${{ env.PROJECT_NAME }}'*/out/'${{ env.PROJECT_NAME }}.bash' "$ARCHIVE_DIR/autocomplete/" - cp 'target/${{ matrix.job.target }}/release/build/${{ env.PROJECT_NAME }}'*/out/'${{ env.PROJECT_NAME }}.fish' "$ARCHIVE_DIR/autocomplete/" - cp 'target/${{ matrix.job.target }}/release/build/${{ env.PROJECT_NAME }}'*/out/'_${{ env.PROJECT_NAME }}.ps1' "$ARCHIVE_DIR/autocomplete/" - cp 'contrib/completion/_fd' "$ARCHIVE_DIR/autocomplete/" + cp -r autocomplete "${ARCHIVE_DIR}" # base compressed package pushd "${PKG_STAGING}/" >/dev/null @@ -256,9 +257,9 @@ jobs: gzip -n --best "${DPKG_DIR}/usr/share/man/man1/${{ env.PROJECT_NAME }}.1" # Autocompletion files - install -Dm644 'target/${{ matrix.job.target }}/release/build/${{ env.PROJECT_NAME }}'*/out/'${{ env.PROJECT_NAME }}.bash' "${DPKG_DIR}/usr/share/bash-completion/completions/${{ env.PROJECT_NAME }}" - install -Dm644 'target/${{ matrix.job.target }}/release/build/${{ env.PROJECT_NAME }}'*/out/'${{ env.PROJECT_NAME }}.fish' "${DPKG_DIR}/usr/share/fish/vendor_completions.d/${{ env.PROJECT_NAME }}.fish" - install -Dm644 'contrib/completion/_fd' "${DPKG_DIR}/usr/share/zsh/vendor-completions/_${{ env.PROJECT_NAME }}" + install -Dm644 'autocomplete/fd.bash' "${DPKG_DIR}/usr/share/bash-completion/completions/${{ env.PROJECT_NAME }}" + install -Dm644 'autocomplete/fd.fish' "${DPKG_DIR}/usr/share/fish/vendor_completions.d/${{ env.PROJECT_NAME }}.fish" + install -Dm644 'autocomplete/_fd' "${DPKG_DIR}/usr/share/zsh/vendor-completions/_${{ env.PROJECT_NAME }}" # README and LICENSE install -Dm644 "README.md" "${DPKG_DIR}/usr/share/doc/${DPKG_BASENAME}/README.md" diff --git a/.gitignore b/.gitignore index 324c57f..fddac43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ +/autocomplete/ **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 130ba23..bd5b6ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,6 +102,7 @@ checksum = "23b71c3ce99b7611011217b366d923f1d0a7e07a92bb2dbf1e84508c673ca3bd" dependencies = [ "atty", "bitflags", + "clap_derive", "clap_lex", "indexmap", "once_cell", @@ -120,6 +121,19 @@ dependencies = [ "clap", ] +[[package]] +name = "clap_derive" +version = "3.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "0.2.3" @@ -269,6 +283,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" diff --git a/Cargo.toml b/Cargo.toml index c78ee50..42ea78e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ path = "src/main.rs" [build-dependencies] clap = { version = "3.2", features = ["cargo"] } -clap_complete = "3.2" version_check = "0.9" [dependencies] @@ -51,10 +50,11 @@ dirs-next = "2.0" normpath = "0.3.2" chrono = "0.4" once_cell = "1.13.1" +clap_complete = {version = "3.2", optional = true} [dependencies.clap] version = "3.2" -features = ["suggestions", "color", "wrap_help", "cargo", "unstable-grouped"] +features = ["suggestions", "color", "wrap_help", "cargo", "unstable-grouped", "derive"] [target.'cfg(unix)'.dependencies] users = "0.11.0" @@ -81,4 +81,6 @@ codegen-units = 1 [features] use-jemalloc = ["jemallocator"] -default = ["use-jemalloc"] +completions = ["clap_complete"] +base = ["use-jemalloc"] +default = ["use-jemalloc", "completions"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ca17ba --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +PROFILE=release +EXE=target/$(PROFILE)/fd +prefix=/usr/local +bindir=$(prefix)/bin +datadir=$(prefix)/share +exe_name=fd + +$(EXE): Cargo.toml src/**/*.rs + cargo build --profile $(PROFILE) + +.PHONY: completions +completions: autocomplete/fd.bash autocomplete/fd.fish autocomplete/fd.ps1 autocomplete/_fd + +comp_dir=@mkdir -p autocomplete + +autocomplete/fd.bash: $(EXE) + $(comp_dir) + $(EXE) --gen-completions bash > $@ + +autocomplete/fd.fish: $(EXE) + $(comp_dir) + $(EXE) --gen-completions fish > $@ + +autocomplete/fd.ps1: $(EXE) + $(comp_dir) + $(EXE) --gen-completions powershell > $@ + +autocomplete/_fd: contrib/completion/_fd + $(comp_dir) + cp $< $@ + +install: $(EXE) completions + install -Dm755 $(EXE) $(DESTDIR)$(bindir)/fd + install -Dm644 autocomplete/fd.bash $(DESTDIR)/$(datadir)/bash-completion/completions/$(exe_name) + install -Dm644 autocomplete/fd.fish $(DESTDIR)/$(datadir)/fish/vendor_completions.d/$(exe_name).fish + install -Dm644 autocomplete/_fd $(DESTDIR)/$(datadir)/zsh/site-functions/_$(exe_name) + install -Dm644 doc/fd.1 $(DESTDIR)/$(datadir)/man/man1/$(exe_name).1 diff --git a/README.md b/README.md index 7af5da6..f630dfa 100644 --- a/README.md +++ b/README.md @@ -612,7 +612,7 @@ chown root:root fd.1.gz sudo cp fd.1.gz /usr/share/man/man1 sudo cp autocomplete/fd.bash /usr/share/bash-completion/completions/fd source /usr/share/bash-completion/completions/fd -fd +fd ``` ### On macOS @@ -676,7 +676,7 @@ With Rust's package manager [cargo](https://github.com/rust-lang/cargo), you can ``` cargo install fd-find ``` -Note that rust version *1.56.0* or later is required. +Note that rust version *1.56.1* or later is required. `make` is also needed for the build. diff --git a/build.rs b/build.rs index d78641a..fc54326 100644 --- a/build.rs +++ b/build.rs @@ -1,11 +1,3 @@ -use std::fs; - -use clap_complete::{generate_to, Shell}; -use Shell::*; -//use clap_complete::shells::Shel{Bash, Fish, PowerShell, Elvish}; - -include!("src/app.rs"); - fn main() { let min_version = "1.56"; @@ -17,17 +9,4 @@ fn main() { std::process::exit(1); } } - - let var = std::env::var_os("SHELL_COMPLETIONS_DIR").or_else(|| std::env::var_os("OUT_DIR")); - let outdir = match var { - None => return, - Some(outdir) => outdir, - }; - fs::create_dir_all(&outdir).unwrap(); - - let mut app = build_app(); - // NOTE: zsh completions are hand written in contrib/completion/_fd - for shell in [Bash, PowerShell, Fish, Elvish] { - generate_to(shell, &mut app, "fd", &outdir).unwrap(); - } } diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index a9eba45..0000000 --- a/src/app.rs +++ /dev/null @@ -1,774 +0,0 @@ -use clap::{crate_version, AppSettings, Arg, ColorChoice, Command}; - -pub fn build_app() -> Command<'static> { - let clap_color_choice = if std::env::var_os("NO_COLOR").is_none() { - ColorChoice::Auto - } else { - ColorChoice::Never - }; - - let mut app = Command::new("fd") - .version(crate_version!()) - .color(clap_color_choice) - .setting(AppSettings::DeriveDisplayOrder) - .dont_collapse_args_in_usage(true) - .after_help( - "Note: `fd -h` prints a short and concise overview while `fd --help` gives all \ - details.", - ) - .arg( - Arg::new("hidden") - .long("hidden") - .short('H') - .overrides_with("hidden") - .help("Search hidden files and directories") - .long_help( - "Include hidden directories and files in the search results (default: \ - hidden files and directories are skipped). Files and directories are \ - considered to be hidden if their name starts with a `.` sign (dot). \ - The flag can be overridden with --no-hidden.", - ), - ) - .arg( - Arg::new("no-hidden") - .long("no-hidden") - .overrides_with("hidden") - .hide(true) - .long_help( - "Overrides --hidden.", - ), - ) - .arg( - Arg::new("no-ignore") - .long("no-ignore") - .short('I') - .overrides_with("no-ignore") - .help("Do not respect .(git|fd)ignore files") - .long_help( - "Show search results from files and directories that would otherwise be \ - ignored by '.gitignore', '.ignore', '.fdignore', or the global ignore file. \ - The flag can be overridden with --ignore.", - ), - ) - .arg( - Arg::new("ignore") - .long("ignore") - .overrides_with("no-ignore") - .hide(true) - .long_help( - "Overrides --no-ignore.", - ), - ) - .arg( - Arg::new("no-ignore-vcs") - .long("no-ignore-vcs") - .overrides_with("no-ignore-vcs") - .hide_short_help(true) - .help("Do not respect .gitignore files") - .long_help( - "Show search results from files and directories that would otherwise be \ - ignored by '.gitignore' files. The flag can be overridden with --ignore-vcs.", - ), - ) - .arg( - Arg::new("ignore-vcs") - .long("ignore-vcs") - .overrides_with("no-ignore-vcs") - .hide(true) - .long_help( - "Overrides --no-ignore-vcs.", - ), - ) - .arg( - Arg::new("no-ignore-parent") - .long("no-ignore-parent") - .overrides_with("no-ignore-parent") - .hide_short_help(true) - .help("Do not respect .(git|fd)ignore files in parent directories") - .long_help( - "Show search results from files and directories that would otherwise be \ - ignored by '.gitignore', '.ignore', or '.fdignore' files in parent directories.", - ), - ) - .arg( - Arg::new("no-global-ignore-file") - .long("no-global-ignore-file") - .hide(true) - .help("Do not respect the global ignore file") - .long_help("Do not respect the global ignore file."), - ) - .arg( - Arg::new("rg-alias-hidden-ignore") - .short('u') - .long("unrestricted") - .overrides_with_all(&["ignore", "no-hidden"]) - .multiple_occurrences(true) // Allowed for historical reasons - .hide_short_help(true) - .help("Unrestricted search, alias for '--no-ignore --hidden'") - .long_help( - "Perform an unrestricted search, including ignored and hidden files. This is \ - an alias for '--no-ignore --hidden'." - ), - ) - .arg( - Arg::new("case-sensitive") - .long("case-sensitive") - .short('s') - .overrides_with_all(&["ignore-case", "case-sensitive"]) - .help("Case-sensitive search (default: smart case)") - .long_help( - "Perform a case-sensitive search. By default, fd uses case-insensitive \ - searches, unless the pattern contains an uppercase character (smart \ - case).", - ), - ) - .arg( - Arg::new("ignore-case") - .long("ignore-case") - .short('i') - .overrides_with_all(&["case-sensitive", "ignore-case"]) - .help("Case-insensitive search (default: smart case)") - .long_help( - "Perform a case-insensitive search. By default, fd uses case-insensitive \ - searches, unless the pattern contains an uppercase character (smart \ - case).", - ), - ) - .arg( - Arg::new("glob") - .long("glob") - .short('g') - .conflicts_with("fixed-strings") - .overrides_with("glob") - .help("Glob-based search (default: regular expression)") - .long_help("Perform a glob-based search instead of a regular expression search."), - ) - .arg( - Arg::new("regex") - .long("regex") - .overrides_with_all(&["glob", "regex"]) - .hide_short_help(true) - .help("Regular-expression based search (default)") - .long_help( - "Perform a regular-expression based search (default). This can be used to \ - override --glob.", - ), - ) - .arg( - Arg::new("fixed-strings") - .long("fixed-strings") - .short('F') - .alias("literal") - .overrides_with("fixed-strings") - .hide_short_help(true) - .help("Treat pattern as literal string instead of regex") - .long_help( - "Treat the pattern as a literal string instead of a regular expression. Note \ - that this also performs substring comparison. If you want to match on an \ - exact filename, consider using '--glob'.", - ), - ) - .arg( - Arg::new("absolute-path") - .long("absolute-path") - .short('a') - .overrides_with("absolute-path") - .help("Show absolute instead of relative paths") - .long_help( - "Shows the full path starting from the root as opposed to relative paths. \ - The flag can be overridden with --relative-path.", - ), - ) - .arg( - Arg::new("relative-path") - .long("relative-path") - .overrides_with("absolute-path") - .hide(true) - .long_help( - "Overrides --absolute-path.", - ), - ) - .arg( - Arg::new("list-details") - .long("list-details") - .short('l') - .conflicts_with("absolute-path") - .help("Use a long listing format with file metadata") - .long_help( - "Use a detailed listing format like 'ls -l'. This is basically an alias \ - for '--exec-batch ls -l' with some additional 'ls' options. This can be \ - used to see more metadata, to show symlink targets and to achieve a \ - deterministic sort order.", - ), - ) - .arg( - Arg::new("follow") - .long("follow") - .short('L') - .alias("dereference") - .overrides_with("follow") - .help("Follow symbolic links") - .long_help( - "By default, fd does not descend into symlinked directories. Using this \ - flag, symbolic links are also traversed. \ - Flag can be overriden with --no-follow.", - ), - ) - .arg( - Arg::new("no-follow") - .long("no-follow") - .overrides_with("follow") - .hide(true) - .long_help( - "Overrides --follow.", - ), - ) - .arg( - Arg::new("full-path") - .long("full-path") - .short('p') - .overrides_with("full-path") - .help("Search full abs. path (default: filename only)") - .long_help( - "By default, the search pattern is only matched against the filename (or \ - directory name). Using this flag, the pattern is matched against the full \ - (absolute) path. Example:\n \ - fd --glob -p '**/.git/config'", - ), - ) - .arg( - Arg::new("null_separator") - .long("print0") - .short('0') - .overrides_with("print0") - .conflicts_with("list-details") - .hide_short_help(true) - .help("Separate results by the null character") - .long_help( - "Separate search results by the null character (instead of newlines). \ - Useful for piping results to 'xargs'.", - ), - ) - .arg( - Arg::new("max-depth") - .long("max-depth") - .short('d') - .takes_value(true) - .value_name("depth") - .help("Set maximum search depth (default: none)") - .long_help( - "Limit the directory traversal to a given depth. By default, there is no \ - limit on the search depth.", - ), - ) - // support --maxdepth as well, for compatibility with rg - .arg( - Arg::new("rg-depth") - .long("maxdepth") - .hide(true) - .takes_value(true) - .help("Set maximum search depth (default: none)") - ) - .arg( - Arg::new("min-depth") - .long("min-depth") - .takes_value(true) - .value_name("depth") - .hide_short_help(true) - .help("Only show results starting at given depth") - .long_help( - "Only show search results starting at the given depth. \ - See also: '--max-depth' and '--exact-depth'", - ), - ) - .arg( - Arg::new("exact-depth") - .long("exact-depth") - .takes_value(true) - .value_name("depth") - .hide_short_help(true) - .conflicts_with_all(&["max-depth", "min-depth"]) - .help("Only show results at exact given depth") - .long_help( - "Only show search results at the exact given depth. This is an alias for \ - '--min-depth --max-depth '.", - ), - ) - .arg( - Arg::new("prune") - .long("prune") - .conflicts_with_all(&["size", "exact-depth"]) - .hide_short_help(true) - .help("Do not traverse into matching directories") - .long_help("Do not traverse into directories that match the search criteria. If \ - you want to exclude specific directories, use the '--exclude=…' option.") - ) - .arg( - Arg::new("file-type") - .long("type") - .short('t') - .multiple_occurrences(true) - .number_of_values(1) - .takes_value(true) - .value_name("filetype") - .possible_values(&[ - "f", - "file", - "d", - "directory", - "l", - "symlink", - "x", - "executable", - "e", - "empty", - "s", - "socket", - "p", - "pipe", - ]) - .hide_possible_values(true) - .help( - "Filter by type: file (f), directory (d), symlink (l),\nexecutable (x), \ - empty (e), socket (s), pipe (p)", - ) - .long_help( - "Filter the search by type:\n \ - 'f' or 'file': regular files\n \ - 'd' or 'directory': directories\n \ - 'l' or 'symlink': symbolic links\n \ - 's' or 'socket': socket\n \ - 'p' or 'pipe': named pipe (FIFO)\n\n \ - 'x' or 'executable': executables\n \ - 'e' or 'empty': empty files or directories\n\n\ - This option can be specified more than once to include multiple file types. \ - Searching for '--type file --type symlink' will show both regular files as \ - well as symlinks. Note that the 'executable' and 'empty' filters work differently: \ - '--type executable' implies '--type file' by default. And '--type empty' searches \ - for empty files and directories, unless either '--type file' or '--type directory' \ - is specified in addition.\n\n\ - Examples:\n \ - - Only search for files:\n \ - fd --type file …\n \ - fd -tf …\n \ - - Find both files and symlinks\n \ - fd --type file --type symlink …\n \ - fd -tf -tl …\n \ - - Find executable files:\n \ - fd --type executable\n \ - fd -tx\n \ - - Find empty files:\n \ - fd --type empty --type file\n \ - fd -te -tf\n \ - - Find empty directories:\n \ - fd --type empty --type directory\n \ - fd -te -td" - ), - ) - .arg( - Arg::new("extension") - .long("extension") - .short('e') - .multiple_occurrences(true) - .number_of_values(1) - .takes_value(true) - .value_name("ext") - .help("Filter by file extension") - .long_help( - "(Additionally) filter search results by their file extension. Multiple \ - allowable file extensions can be specified.\n\ - If you want to search for files without extension, \ - you can use the regex '^[^.]+$' as a normal search pattern.", - ), - ) - .arg( - Arg::new("exec") - .long("exec") - .short('x') - .min_values(1) - .multiple_occurrences(true) - .allow_hyphen_values(true) - .value_terminator(";") - .value_name("cmd") - .conflicts_with("list-details") - .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). \ - All positional arguments following --exec are considered to be arguments to the command - not to fd. \ - It is therefore recommended to place the '-x'/'--exec' 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\n\ - If no placeholder is present, an implicit \"{}\" at the end is assumed.\n\n\ - Examples:\n\n \ - - find all *.zip files and unzip them:\n\n \ - fd -e zip -x unzip\n\n \ - - find *.h and *.cpp files and run \"clang-format -i ..\" for each of them:\n\n \ - fd -e h -e cpp -x clang-format -i\n\n \ - - Convert all *.jpg files to *.png files:\n\n \ - fd -e jpg -x convert {} {.}.png\ - ", - ), - ) - .arg( - Arg::new("exec-batch") - .long("exec-batch") - .short('X') - .min_values(1) - .multiple_occurrences(true) - .allow_hyphen_values(true) - .value_terminator(";") - .value_name("cmd") - .conflicts_with_all(&["exec", "list-details"]) - .help("Execute a command with all search results at once") - .long_help( - "Execute the given command once, with all search results as arguments.\n\ - One of the following placeholders is substituted before the command is executed:\n \ - '{}': path (of all search results)\n \ - '{/}': basename\n \ - '{//}': parent directory\n \ - '{.}': path without file extension\n \ - '{/.}': basename without file extension\n\n\ - If no placeholder is present, an implicit \"{}\" at the end is assumed.\n\n\ - Examples:\n\n \ - - Find all test_*.py files and open them in your favorite editor:\n\n \ - 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("batch-size") - .long("batch-size") - .takes_value(true) - .value_name("size") - .hide_short_help(true) - .requires("exec-batch") - .help("Max number of arguments to run as a batch with -X") - .long_help( - "Maximum number of arguments to pass to the command given with -X. \ - If the number of results is greater than the given size, \ - the command given with -X is run again with remaining arguments. \ - A batch size of zero means there is no limit (default), but note \ - that batching might still happen due to OS restrictions on the \ - maximum length of command lines.", - ), - ) - .arg( - Arg::new("exclude") - .long("exclude") - .short('E') - .takes_value(true) - .value_name("pattern") - .number_of_values(1) - .multiple_occurrences(true) - .help("Exclude entries that match the given glob pattern") - .long_help( - "Exclude files/directories that match the given glob pattern. This \ - overrides any other ignore logic. Multiple exclude patterns can be \ - specified.\n\n\ - Examples:\n \ - --exclude '*.pyc'\n \ - --exclude node_modules", - ), - ) - .arg( - Arg::new("ignore-file") - .long("ignore-file") - .takes_value(true) - .value_name("path") - .number_of_values(1) - .multiple_occurrences(true) - .hide_short_help(true) - .help("Add custom ignore-file in '.gitignore' format") - .long_help( - "Add a custom ignore-file in '.gitignore' format. These files have a low \ - precedence.", - ), - ) - .arg( - Arg::new("color") - .long("color") - .short('c') - .takes_value(true) - .value_name("when") - .possible_values(&["never", "auto", "always"]) - .hide_possible_values(true) - .help("When to use colors: never, *auto*, always") - .long_help( - "Declare when to use color for the pattern match output:\n \ - 'auto': show colors if the output goes to an interactive console (default)\n \ - 'never': do not use colorized output\n \ - 'always': always use colorized output", - ), - ) - .arg( - Arg::new("threads") - .long("threads") - .short('j') - .takes_value(true) - .value_name("num") - .hide_short_help(true) - .help("Set number of threads") - .long_help( - "Set number of threads to use for searching & executing (default: number \ - of available CPU cores)", - ), - ) - .arg( - Arg::new("size") - .long("size") - .short('S') - .takes_value(true) - .number_of_values(1) - .allow_hyphen_values(true) - .multiple_occurrences(true) - .help("Limit results based on the size of files") - .long_help( - "Limit results based on the size of files using the format <+->.\n \ - '+': file size must be greater than or equal to this\n \ - '-': file size must be less than or equal to this\n\ - If neither '+' nor '-' is specified, file size must be exactly equal to this.\n \ - 'NUM': The numeric size (e.g. 500)\n \ - 'UNIT': The units for NUM. They are not case-sensitive.\n\ - Allowed unit values:\n \ - 'b': bytes\n \ - 'k': kilobytes (base ten, 10^3 = 1000 bytes)\n \ - 'm': megabytes\n \ - 'g': gigabytes\n \ - 't': terabytes\n \ - 'ki': kibibytes (base two, 2^10 = 1024 bytes)\n \ - 'mi': mebibytes\n \ - 'gi': gibibytes\n \ - 'ti': tebibytes", - ), - ) - .arg( - Arg::new("max-buffer-time") - .long("max-buffer-time") - .takes_value(true) - .hide(true) - .help("Milliseconds to buffer before streaming search results to console") - .long_help( - "Amount of time in milliseconds to buffer, before streaming the search \ - results to the console.", - ), - ) - .arg( - Arg::new("changed-within") - .long("changed-within") - .alias("change-newer-than") - .alias("newer") - .takes_value(true) - .value_name("date|dur") - .number_of_values(1) - .help("Filter by file modification time (newer than)") - .long_help( - "Filter results based on the file modification time. The argument can be provided \ - as a specific point in time (YYYY-MM-DD HH:MM:SS) or as a duration (10h, 1d, 35min). \ - If the time is not specified, it defaults to 00:00:00. \ - '--change-newer-than' or '--newer' can be used as aliases.\n\ - Examples:\n \ - --changed-within 2weeks\n \ - --change-newer-than '2018-10-27 10:00:00'\n \ - --newer 2018-10-27", - ), - ) - .arg( - Arg::new("changed-before") - .long("changed-before") - .alias("change-older-than") - .alias("older") - .takes_value(true) - .value_name("date|dur") - .number_of_values(1) - .help("Filter by file modification time (older than)") - .long_help( - "Filter results based on the file modification time. The argument can be provided \ - as a specific point in time (YYYY-MM-DD HH:MM:SS) or as a duration (10h, 1d, 35min). \ - '--change-older-than' or '--older' can be used as aliases.\n\ - Examples:\n \ - --changed-before '2018-10-27 10:00:00'\n \ - --change-older-than 2weeks\n \ - --older 2018-10-27", - ), - ) - .arg( - Arg::new("max-results") - .long("max-results") - .takes_value(true) - .value_name("count") - // We currently do not support --max-results in combination with - // program execution because the results that come up in a --max-results - // search are non-deterministic. Users might think that they can run the - // same search with `--exec rm` attached and get a reliable removal of - // the files they saw in the previous search. - .conflicts_with_all(&["exec", "exec-batch", "list-details"]) - .hide_short_help(true) - .help("Limit number of search results") - .long_help("Limit the number of search results to 'count' and quit immediately."), - ) - .arg( - Arg::new("max-one-result") - .short('1') - .hide_short_help(true) - .overrides_with("max-results") - .conflicts_with_all(&["exec", "exec-batch", "list-details"]) - .help("Limit search to a single result") - .long_help("Limit the search to a single result and quit immediately. \ - This is an alias for '--max-results=1'.") - ) - .arg( - Arg::new("quiet") - .long("quiet") - .short('q') - .alias("has-results") - .hide_short_help(true) - .conflicts_with_all(&["exec", "exec-batch", "list-details", "max-results"]) - .help("Print nothing, exit code 0 if match found, 1 otherwise") - .long_help( - "When the flag is present, the program does not print anything and will \ - return with an exit code of 0 if there is at least one match. Otherwise, the \ - exit code will be 1. \ - '--has-results' can be used as an alias." - ) - ) - .arg( - Arg::new("show-errors") - .long("show-errors") - .hide_short_help(true) - .overrides_with("show-errors") - .help("Show filesystem errors") - .long_help( - "Enable the display of filesystem errors for situations such as \ - insufficient permissions or dead symlinks.", - ), - ) - .arg( - Arg::new("base-directory") - .long("base-directory") - .takes_value(true) - .value_name("path") - .number_of_values(1) - .allow_invalid_utf8(true) - .hide_short_help(true) - .help("Change current working directory") - .long_help( - "Change the current working directory of fd to the provided path. This \ - means that search results will be shown with respect to the given base \ - path. Note that relative paths which are passed to fd via the positional \ - argument or the '--search-path' option will also be resolved \ - relative to this directory.", - ), - ) - .arg( - Arg::new("pattern") - .allow_invalid_utf8(true) - .help( - "the search pattern (a regular expression, unless '--glob' is used; optional)", - ).long_help( - "the search pattern which is either a regular expression (default) or a glob \ - pattern (if --glob is used). If no pattern has been specified, every entry \ - is considered a match. If your pattern starts with a dash (-), make sure to \ - pass '--' first, or it will be considered as a flag (fd -- '-foo').") - ) - .arg( - Arg::new("path-separator") - .takes_value(true) - .value_name("separator") - .long("path-separator") - .hide_short_help(true) - .help("Set path separator when printing file paths") - .long_help( - "Set the path separator to use when printing file paths. The default is \ - the OS-specific separator ('/' on Unix, '\\' on Windows).", - ), - ) - .arg( - Arg::new("path") - .multiple_occurrences(true) - .allow_invalid_utf8(true) - .help("the root directory for the filesystem search (optional)") - .long_help( - "The directory where the filesystem search is rooted (optional). If \ - omitted, search the current working directory.", - ), - ) - .arg( - Arg::new("search-path") - .long("search-path") - .takes_value(true) - .conflicts_with("path") - .multiple_occurrences(true) - .hide_short_help(true) - .number_of_values(1) - .allow_invalid_utf8(true) - .help("Provide paths to search as an alternative to the positional ") - .long_help( - "Provide paths to search as an alternative to the positional \ - argument. Changes the usage to `fd [OPTIONS] --search-path \ - --search-path []`", - ), - ) - .arg( - Arg::new("strip-cwd-prefix") - .long("strip-cwd-prefix") - .conflicts_with_all(&["path", "search-path"]) - .hide_short_help(true) - .help("strip './' prefix from non-tty outputs") - .long_help( - "By default, relative paths are prefixed with './' when the output goes to a non \ - interactive terminal (TTY). Use this flag to disable this behaviour." - ) - ); - - if cfg!(unix) { - app = app.arg( - Arg::new("owner") - .long("owner") - .short('o') - .takes_value(true) - .value_name("user:group") - .help("Filter by owning user and/or group") - .long_help( - "Filter files by their user and/or group. \ - Format: [(user|uid)][:(group|gid)]. Either side is optional. \ - Precede either side with a '!' to exclude files instead.\n\ - Examples:\n \ - --owner john\n \ - --owner :students\n \ - --owner '!john:students'", - ), - ); - } - - // Make `--one-file-system` available only on Unix and Windows platforms, as per the - // restrictions on the corresponding option in the `ignore` crate. - // Provide aliases `mount` and `xdev` for people coming from `find`. - if cfg!(any(unix, windows)) { - app = app.arg( - Arg::new("one-file-system") - .long("one-file-system") - .aliases(&["mount", "xdev"]) - .hide_short_help(true) - .help("Do not descend into a different file system") - .long_help( - "By default, fd will traverse the file system tree as far as other options \ - dictate. With this flag, fd ensures that it does not descend into a \ - different file system than the one it started in. Comparable to the -mount \ - or -xdev filters of find(1).", - ), - ); - } - - app -} - -#[test] -fn verify_app() { - build_app().debug_assert() -} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..3d1c804 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,747 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[cfg(feature = "completions")] +use anyhow::anyhow; +use clap::{ + builder::RangedU64ValueParser, value_parser, AppSettings, Arg, ArgAction, ArgEnum, ArgGroup, + ArgMatches, Command, ErrorKind, Parser, +}; +#[cfg(feature = "completions")] +use clap_complete::Shell; +use normpath::PathExt; + +use crate::error::print_error; +use crate::exec::CommandSet; +use crate::filesystem; +#[cfg(unix)] +use crate::filter::OwnerFilter; +use crate::filter::SizeFilter; + +// Type for options that don't have any values, but are used to negate +// earlier options +struct Negations; + +impl clap::FromArgMatches for Negations { + fn from_arg_matches(_: &ArgMatches) -> clap::Result { + Ok(Negations) + } + + fn update_from_arg_matches(&mut self, _: &ArgMatches) -> clap::Result<()> { + Ok(()) + } +} + +impl clap::Args for Negations { + fn augment_args(cmd: Command<'_>) -> Command<'_> { + Self::augment_args_for_update(cmd) + } + + fn augment_args_for_update(cmd: Command<'_>) -> Command<'_> { + cmd.arg( + Arg::new("no-hidden") + .long("no-hidden") + .overrides_with("hidden") + .hide(true) + .long_help("Overrides --hidden."), + ) + .arg( + Arg::new("ignore") + .long("ignore") + .overrides_with("no-ignore") + .hide(true) + .long_help("Overrides --no-ignore."), + ) + .arg( + Arg::new("ignore-vcs") + .long("ignore-vcs") + .overrides_with("no-ignore-vcs") + .hide(true) + .long_help("Overrides --no-ignore-vcs."), + ) + .arg( + Arg::new("relative-path") + .long("relative-path") + .overrides_with("absolute-path") + .hide(true) + .long_help("Overrides --absolute-path."), + ) + .arg( + Arg::new("no-follow") + .long("no-follow") + .overrides_with("follow") + .hide(true) + .long_help("Overrides --follow."), + ) + } +} + +#[derive(Parser)] +#[clap( + version, + setting(AppSettings::DeriveDisplayOrder), + dont_collapse_args_in_usage = true, + after_help = "Note: `fd -h` prints a short and concise overview while `fd --help` gives all \ + details.", + group(ArgGroup::new("execs").args(&["exec", "exec-batch", "list-details"]).conflicts_with_all(&[ + "max-results", "has-results", "count"])), +)] +pub struct Opts { + /// Search hidden files and directories + /// + /// Include hidden directories and files in the search results (default: + /// hidden files and directories are skipped). Files and directories are considered + /// to be hidden if their name starts with a `.` sign (dot). + /// The flag can be overriden with --no-hidden. + #[clap(long, short = 'H', action, overrides_with = "hidden")] + pub hidden: bool, + /// Do not respect .(git|fd)ignore files + /// + /// Show search results from files and directories that would otherwise be + /// ignored by '.gitignore', '.ignore', '.fdignore', or the global ignore file. + /// The flag can be overridden with --ignore. + #[clap(long, short = 'I', action, overrides_with = "no-ignore")] + pub no_ignore: bool, + /// Do not respect .gitignore files + /// + ///Show search results from files and directories that would otherwise be + ///ignored by '.gitignore' files. The flag can be overridden with --ignore-vcs. + #[clap(long, action, overrides_with = "no-ignore-vcs", hide_short_help = true)] + pub no_ignore_vcs: bool, + /// Do not respect .(git|fd)ignore files in parent directories + /// + /// Show search results from files and directories that would otherwise be + /// ignored by '.gitignore', '.ignore', or '.fdignore' files in parent directories. + #[clap( + long, + action, + overrides_with = "no-ignore-parent", + hide_short_help = true + )] + pub no_ignore_parent: bool, + /// Do not respect the global ignore file + #[clap(long, action, hide = true)] + pub no_global_ignore_file: bool, + /// Unrestricted search, alias for '--no-ignore --hidden' + /// + ///Perform an unrestricted search, including ignored and hidden files. This is + ///an alias for '--no-ignore --hidden'. + #[clap(long = "unrestricted", short = 'u', overrides_with_all(&["ignore", "no-hidden"]), action(ArgAction::Count), hide_short_help = true)] + rg_alias_hidden_ignore: u8, + /// Case-sensitive search (default: smart case) + /// + ///Perform a case-sensitive search. By default, fd uses case-insensitive + ///searches, unless the pattern contains an uppercase character (smart case). + #[clap(long, short = 's', action, overrides_with_all(&["ignore-case", "case-sensitive"]))] + pub case_sensitive: bool, + /// Case-insensitive search (default: smart case) + /// + /// Perform a case-insensitive search. By default, fd uses case-insensitive searches, unless + /// the pattern contains an uppercase character (smart case). + #[clap(long, short = 'i', action, overrides_with_all(&["case-sensitive", "ignore-case"]))] + pub ignore_case: bool, + /// Glob-based search (default: regular expression) + /// + /// Perform a glob-based search instead of a regular expression search. + #[clap( + long, + short = 'g', + action, + conflicts_with("fixed-strings"), + overrides_with("glob") + )] + pub glob: bool, + /// Regular-expression based search (default) + /// + ///Perform a regular-expression based search (default). This can be used to override --glob. + #[clap(long, action, overrides_with_all(&["glob", "regex"]), hide_short_help = true)] + pub regex: bool, + /// Treat pattern as literal string instead of regex + /// + /// Treat the pattern as a literal string instead of a regular expression. Note + /// that this also performs substring comparison. If you want to match on an + /// exact filename, consider using '--glob'. + #[clap( + long, + short = 'F', + alias = "literal", + overrides_with("fixed-strings"), + hide_short_help = true + )] + pub fixed_strings: bool, + /// Show absolute instead of relative paths + /// + /// Shows the full path starting with the root as opposed to relative paths. + /// The flag can be overridden with --relative-path. + #[clap(long, short = 'a', action, overrides_with("absolute-path"))] + pub absolute_path: bool, + /// Use a long listing format with file metadata + /// + /// Use a detailed listing format like 'ls -l'. This is basically an alias + /// for '--exec-batch ls -l' with some additional 'ls' options. This can be + /// used to see more metadata, to show symlink targets and to achieve a + /// deterministic sort order. + #[clap(long, short = 'l', action, conflicts_with("absolute-path"))] + pub list_details: bool, + /// Follow symbolic links + /// + /// By default, fd does not descend into symlinked directories. Using this + /// flag, symbolic links are also traversed. + /// Flag can be overriden with --no-follow. + #[clap( + long, + short = 'L', + alias = "dereference", + action, + overrides_with("follow") + )] + pub follow: bool, + /// Search full abs. path (default: filename only) + /// + /// By default, the search pattern is only matched against the filename (or + /// directory name). Using this flag, the pattern is matched against the full + /// (absolute) path. Example: + /// fd --glob -p '**/.git/config' + #[clap(long, short = 'p', action, overrides_with("full-path"))] + pub full_path: bool, + /// Separate results by the null character + /// + /// Separate search results by the null character (instead of newlines). + /// Useful for piping results to 'xargs'. + #[clap( + long = "print0", + short = '0', + action, + overrides_with("print0"), + conflicts_with("list-details"), + hide_short_help = true + )] + pub null_separator: bool, + /// Set maximum search depth (default: none) + /// + /// Limit the directory traversal to a given depth. By default, there is no + /// limit on the search depth. + #[clap( + long, + short = 'd', + value_name = "depth", + value_parser, + alias("maxdepth") + )] + max_depth: Option, + /// Only show results starting at given depth + /// + /// Only show search results starting at the given depth. + /// See also: '--max-depth' and '--exact-depth' + #[clap(long, value_name = "depth", hide_short_help = true, value_parser)] + min_depth: Option, + /// Only show results at exact given depth + /// + /// Only show search results at the exact given depth. This is an alias for + /// '--min-depth --max-depth '. + #[clap(long, value_name = "depth", hide_short_help = true, value_parser, conflicts_with_all(&["max-depth", "min-depth"]))] + exact_depth: Option, + /// Do not travers into matching directories + /// + /// Do not traverse into directories that match the search criteria. If + /// you want to exclude specific directories, use the '--exclude=…' option. + #[clap(long, hide_short_help = true, action, conflicts_with_all(&["size", "exact-depth"]))] + pub prune: bool, + /// Filter by type: file (f), directory (d), symlink (l),\nexecutable (x), + /// empty (e), socket (s), pipe (p)) + /// + /// Filter the search by type: + /// + /// 'f' or 'file': regular files + /// 'd' or 'directory': directories + /// 'l' or 'symlink': symbolic links + /// 's' or 'socket': socket + /// 'p' or 'pipe': named pipe (FIFO) + /// + /// 'x' or 'executable': executables + /// 'e' or 'empty': empty files or directories + /// + /// This option can be specified more than once to include multiple file types. + /// Searching for '--type file --type symlink' will show both regular files as + /// well as symlinks. Note that the 'executable' and 'empty' filters work differently: + /// '--type executable' implies '--type file' by default. And '--type empty' searches + /// for empty files and directories, unless either '--type file' or '--type directory' + /// is specified in addition. + /// + /// Examples: + /// + /// - Only search for files: + /// fd --type file … + /// fd -tf … + /// - Find both files and symlinks + /// fd --type file --type symlink … + /// fd -tf -tl … + /// - Find executable files: + /// fd --type executable + /// fd -tx + /// - Find empty files: + /// fd --type empty --type file + /// fd -te -tf + /// - Find empty directories: + /// fd --type empty --type directory + /// fd -te -td" + #[clap(long = "type", short = 't', value_name = "filetype", hide_possible_values = true, + arg_enum, action = ArgAction::Append, number_of_values = 1)] + pub filetype: Option>, + /// Filter by file extension + /// + /// (Additionally) filter search results by their file extension. Multiple + /// allowable file extensions can be specified. + /// + /// If you want to search for files without extension, + /// you can use the regex '^[^.]+$' as a normal search pattern. + #[clap(long = "extension", short = 'e', value_name = "ext", action = ArgAction::Append, number_of_values = 1)] + pub extensions: Option>, + + #[clap(flatten)] + pub exec: Exec, + + /// Max number of arguments to run as a batch with -X + /// + /// Maximum number of arguments to pass to the command given with -X. + /// If the number of results is greater than the given size, + /// the command given with -X is run again with remaining arguments. + /// A batch size of zero means there is no limit (default), but note + /// that batching might still happen due to OS restrictions on the + /// maximum length of command lines. + #[clap( + long, + value_name = "size", + hide_short_help = true, + requires("exec-batch"), + value_parser = value_parser!(usize), + default_value_t + )] + pub batch_size: usize, + /// Exclude entries that match the given glob pattern + /// + /// "Exclude files/directories that match the given glob pattern. This + /// overrides any other ignore logic. Multiple exclude patterns can be + /// specified. + /// + /// Examples: + /// --exclude '*.pyc' + /// --exclude node_modules + #[clap(long, short = 'E', value_name = "pattern", action = ArgAction::Append, number_of_values = 1)] + pub exclude: Vec, + /// Add custom ignore-file in '.gitignore' format + /// + /// Add a custom ignore-file in '.gitignore' format. These files have a low + /// precedence. + #[clap(long, value_name = "path", action = ArgAction::Append, number_of_values = 1, hide_short_help = true)] + pub ignore_file: Vec, + /// When to use colors + #[clap( + long, + short = 'c', + arg_enum, + default_value = "auto", + value_name = "when" + )] + pub color: ColorWhen, + /// Set number of threads + /// + /// Set number of threads to use for searching & executing (default: number + /// of available CPU cores) + #[clap(long, short = 'j', value_name = "num", hide_short_help = true, value_parser = RangedU64ValueParser::::from(1..))] + pub threads: Option, + /// Limit results based on the size of files + /// + /// Limit results based on the size of files using the format <+->. + /// '+': file size must be greater than or equal to this + /// '-': file size must be less than or equal to this + /// If neither '+' nor '-' is specified, file size must be exactly equal to this. + /// 'NUM': The numeric size (e.g. 500) + /// 'UNIT': The units for NUM. They are not case-sensitive. + /// Allowed unit values: + /// 'b': bytes + /// 'k': kilobytes (base ten, 10^3 = 1000 bytes) + /// 'm': megabytes + /// 'g': gigabytes + /// 't': terabytes + /// 'ki': kibibytes (base two, 2^10 = 1024 bytes) + /// 'mi': mebibytes + /// 'gi': gibibytes + /// 'ti': tebibytes + #[clap(long, short = 'S', number_of_values = 1, value_parser = SizeFilter::from_string, allow_hyphen_values = true, action = ArgAction::Append)] + pub size: Vec, + /// Milliseconds to buffer before streaming search results to console + /// + /// Amount of time in milliseconds to buffer, before streaming the search + /// results to the console. + #[clap(long, hide = true, action, value_parser = parse_millis)] + pub max_buffer_time: Option, + /// Filter by file modification time (newer than) + /// + /// Filter results based on the file modification time. The argument can be provided + /// as a specific point in time (YYYY-MM-DD HH:MM:SS) or as a duration (10h, 1d, 35min). + /// If the time is not specified, it defaults to 00:00:00. + /// '--change-newer-than' or '--newer' can be used as aliases. + /// Examples: + /// --changed-within 2weeks + /// --change-newer-than '2018-10-27 10:00:00' + /// --newer 2018-10-27 + #[clap( + long, + alias("change-newer-than"), + alias("newer"), + value_name = "date|dur", + number_of_values = 1, + action + )] + pub changed_within: Option, + /// Filter by file modification time (older than) + /// + /// Filter results based on the file modification time. The argument can be provided + /// as a specific point in time (YYYY-MM-DD HH:MM:SS) or as a duration (10h, 1d, 35min). + /// '--change-older-than' or '--older' can be used as aliases. + /// + /// Examples: + /// --changed-before '2018-10-27 10:00:00' + /// --change-older-than 2weeks + /// --older 2018-10-27 + #[clap( + long, + alias("change-older-than"), + alias("older"), + value_name = "date|dur", + number_of_values = 1, + action + )] + pub changed_before: Option, + /// Limit number of search results + /// + /// Limit the number of search results to 'count' and quit immediately. + #[clap(long, value_name = "count", hide_short_help = true, value_parser)] + max_results: Option, + /// Limit search to a single result + /// + /// Limit the search to a single result and quit immediately. + /// This is an alias for '--max-results=1'. + #[clap( + short = '1', + hide_short_help = true, + overrides_with("max-results"), + action + )] + max_one_result: bool, + /// Print nothing, exit code 0 if match found, 1 otherwise + /// + /// When the flag is present, the program does not print anything and will + /// return with an exit code of 0 if there is at least one match. Otherwise, the + /// exit code will be 1. + /// + /// '--has-results' can be used as an alias. + #[clap(long, short = 'q', alias = "has-results", hide_short_help = true, conflicts_with("max-results"), action)] + pub quiet: bool, + /// Show filesystem errors + /// + ///Enable the display of filesystem errors for situations such as + ///insufficient permissions or dead symlinks. + #[clap(long, hide_short_help = true, overrides_with("show-errors"), action)] + pub show_errors: bool, + /// Change current working directory + /// + /// Change the current working directory of fd to the provided path. This + /// means that search results will be shown with respect to the given base + /// path. Note that relative paths which are passed to fd via the positional + /// argument or the '--search-path' option will also be resolved + /// relative to this directory. + #[clap( + long, + value_name = "path", + number_of_values = 1, + action, + hide_short_help = true + )] + pub base_directory: Option, + /// the search pattern (a regular expression, unless '--glob' is used; optional) + /// + /// the search pattern which is either a regular expression (default) or a glob + /// pattern (if --glob is used). If no pattern has been specified, every entry + /// is considered a match. If your pattern starts with a dash (-), make sure to + /// pass '--' first, or it will be considered as a flag (fd -- '-foo'). + #[clap(value_parser, default_value = "")] + pub pattern: String, + /// Set path separator when printing file paths + /// Set the path separator to use when printing file paths. The default is + /// the OS-specific separator ('/' on Unix, '\\' on Windows). + #[clap(long, value_name = "separator", hide_short_help = true, action)] + pub path_separator: Option, + /// the root directories for the filesystem search (optional) + /// + /// The directories where the filesystem search is rooted (optional). + /// If omitted, search the current working directory. + #[clap(action = ArgAction::Append)] + path: Vec, + /// Provides paths to search as an alternative to the positional + /// + /// Provide paths to search as an alternative to the positional + /// argument. Changes the usage to `fd [OPTIONS] --search-path + /// --search-path []` + #[clap(long, conflicts_with("path"), action = ArgAction::Append, hide_short_help = true, number_of_values = 1)] + search_path: Vec, + /// strip './' prefix from non-tty outputs + /// + /// By default, relative paths are prefixed with './' when the output goes to a non + /// interactive terminal (TTY). Use this flag to disable this behaviour. + #[clap(long, conflicts_with_all(&["path", "search-path"]), hide_short_help = true, action)] + pub strip_cwd_prefix: bool, + /// Filter by owning user and/or group + /// + /// Filter files by their user and/or group. + /// Format: [(user|uid)][:(group|gid)]. Either side is optional. + /// Precede either side with a '!' to exclude files instead. + /// + /// Examples: + /// --owner john + /// --owner :students + /// --owner '!john:students' + #[cfg(unix)] + #[clap(long, short = 'o', value_parser = OwnerFilter::from_string, value_name = "user:group")] + pub owner: Option, + /// Do not descend into a different file system + /// + /// By default, fd will traverse the file system tree as far as other options + /// dictate. With this flag, fd ensures that it does not descend into a + /// different file system than the one it started in. Comparable to the -mount + /// or -xdev filters of find(1). + #[cfg(any(unix, windows))] + #[clap(long, aliases(&["mount", "xdev"]), hide_short_help = true)] + pub one_file_system: bool, + + #[cfg(feature = "completions")] + #[clap(long, value_parser = value_parser!(Shell), hide = true, exclusive = true)] + gen_completions: Option>, + + #[clap(flatten)] + _negations: Negations, +} + +impl Opts { + pub fn search_paths(&self) -> anyhow::Result> { + // would it make sense to concatenate these? + let paths = if !self.path.is_empty() { + &self.path + } else if !self.search_path.is_empty() { + &self.search_path + } else { + let current_directory = Path::new("."); + ensure_current_directory_exists(current_directory)?; + return Ok(vec![self.normalize_path(current_directory)]); + }; + Ok(paths + .iter() + .filter_map(|path| { + if filesystem::is_existing_directory(&path) { + Some(self.normalize_path(path)) + } else { + print_error(format!( + "Search path '{}' is not a directory.", + path.to_string_lossy() + )); + None + } + }) + .collect()) + } + + fn normalize_path(&self, path: &Path) -> PathBuf { + if self.absolute_path { + filesystem::absolute_path(path.normalize().unwrap().as_path()).unwrap() + } else { + path.to_path_buf() + } + } + + pub fn no_search_paths(&self) -> bool { + self.path.is_empty() && self.search_path.is_empty() + } + + #[inline] + pub fn rg_alias_ignore(&self) -> bool { + self.rg_alias_hidden_ignore > 0 + } + + pub fn max_depth(&self) -> Option { + self.max_depth.or(self.exact_depth) + } + + pub fn min_depth(&self) -> Option { + self.min_depth.or(self.exact_depth) + } + + pub fn threads(&self) -> usize { + std::cmp::max(self.threads.unwrap_or_else(num_cpus::get), 1) + } + + pub fn max_results(&self) -> Option { + self.max_results.filter(|&m| m > 0).or_else(|| self.max_one_result.then(|| 1)) + } + + #[cfg(feature = "completions")] + pub fn gen_completions(&self) -> anyhow::Result> { + self.gen_completions + .map(|maybe_shell| match maybe_shell { + Some(sh) => Ok(sh), + None => guess_shell(), + }) + .transpose() + } +} + +// TODO: windows? +#[cfg(feature = "completions")] +fn guess_shell() -> anyhow::Result { + let env_shell = std::env::var_os("SHELL").map(PathBuf::from); + let shell = env_shell.as_ref() + .and_then(|s| s.file_name()) + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow!("Unable to get shell from environment"))?; + shell + .parse::() + .map_err(|_| anyhow!("Unknown shell {}", shell)) +} + +#[derive(Copy, Clone, PartialEq, Eq, ArgEnum)] +pub enum FileType { + #[clap(alias = "f")] + File, + #[clap(alias = "d")] + Directory, + #[clap(alias = "l")] + Symlink, + #[clap(alias = "x")] + Executable, + #[clap(alias = "e")] + Empty, + #[clap(alias = "s")] + Socket, + #[clap(alias = "p")] + Pipe, +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug, ArgEnum)] +pub enum ColorWhen { + /// show colors if the output goes to an interactive console (default) + Auto, + /// always use colorized output + Always, + /// do not use colorized output + Never, +} + +// there isn't a derive api for getting grouped values yet, +// so we have to use hand-rolled parsing for exec and exec-batch +pub struct Exec { + pub command: Option, +} + +impl clap::FromArgMatches for Exec { + fn from_arg_matches(matches: &ArgMatches) -> clap::Result { + let command = matches + .grouped_values_of("exec") + .map(CommandSet::new) + .or_else(|| { + matches + .grouped_values_of("exec-batch") + .map(CommandSet::new_batch) + }) + .transpose() + .map_err(|e| clap::Error::raw(ErrorKind::InvalidValue, e))?; + Ok(Exec { command }) + } + + fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> clap::Result<()> { + *self = Self::from_arg_matches(matches)?; + Ok(()) + } +} + +impl clap::Args for Exec { + fn augment_args(cmd: Command<'_>) -> Command<'_> { + cmd.arg(Arg::new("exec") + .long("exec") + .short('x') + .min_values(1) + .multiple_occurrences(true) + .allow_hyphen_values(true) + .value_terminator(";") + .value_name("cmd") + .conflicts_with("list-details") + .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). \ + All positional arguments following --exec are considered to be arguments to the command - not to fd. \ + It is therefore recommended to place the '-x'/'--exec' 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\n\ + If no placeholder is present, an implicit \"{}\" at the end is assumed.\n\n\ + Examples:\n\n \ + - find all *.zip files and unzip them:\n\n \ + fd -e zip -x unzip\n\n \ + - find *.h and *.cpp files and run \"clang-format -i ..\" for each of them:\n\n \ + fd -e h -e cpp -x clang-format -i\n\n \ + - Convert all *.jpg files to *.png files:\n\n \ + fd -e jpg -x convert {} {.}.png\ + ", + ), + ) + .arg( + Arg::new("exec-batch") + .long("exec-batch") + .short('X') + .min_values(1) + .multiple_occurrences(true) + .allow_hyphen_values(true) + .value_terminator(";") + .value_name("cmd") + .conflicts_with_all(&["exec", "list-details"]) + .help("Execute a command with all search results at once") + .long_help( + "Execute the given command once, with all search results as arguments.\n\ + One of the following placeholders is substituted before the command is executed:\n \ + '{}': path (of all search results)\n \ + '{/}': basename\n \ + '{//}': parent directory\n \ + '{.}': path without file extension\n \ + '{/.}': basename without file extension\n\n\ + If no placeholder is present, an implicit \"{}\" at the end is assumed.\n\n\ + Examples:\n\n \ + - Find all test_*.py files and open them in your favorite editor:\n\n \ + 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\ + " + ), + ) + } + + fn augment_args_for_update(cmd: Command<'_>) -> Command<'_> { + Self::augment_args(cmd) + } +} + +fn parse_millis(arg: &str) -> Result { + Ok(Duration::from_millis(arg.parse()?)) +} + +fn ensure_current_directory_exists(current_directory: &Path) -> anyhow::Result<()> { + if filesystem::is_existing_directory(current_directory) { + Ok(()) + } else { + Err(anyhow!( + "Could not retrieve current directory (has it been deleted?)." + )) + } +} + diff --git a/src/exec/job.rs b/src/exec/job.rs index 9b95ac2..c4d93ee 100644 --- a/src/exec/job.rs +++ b/src/exec/job.rs @@ -17,6 +17,7 @@ pub fn job( out_perm: Arc>, show_filesystem_errors: bool, buffer_output: bool, + path_separator: Option<&str>, ) -> ExitCode { let mut results: Vec = Vec::new(); loop { @@ -39,7 +40,12 @@ pub fn job( // Drop the lock so that other threads can read from the receiver. drop(lock); // Generate a command, execute it and store its exit code. - results.push(cmd.execute(dir_entry.path(), Arc::clone(&out_perm), buffer_output)) + results.push(cmd.execute( + dir_entry.path(), + path_separator, + Arc::clone(&out_perm), + buffer_output, + )) } // Returns error in case of any error. merge_exitcodes(results) @@ -50,6 +56,7 @@ pub fn batch( cmd: &CommandSet, show_filesystem_errors: bool, limit: usize, + path_separator: Option<&str>, ) -> ExitCode { let paths = rx .into_iter() @@ -63,5 +70,5 @@ pub fn batch( } }); - cmd.execute_batch(paths, limit) + cmd.execute_batch(paths, limit, path_separator) } diff --git a/src/exec/mod.rs b/src/exec/mod.rs index fc26da2..21ebfea 100644 --- a/src/exec/mod.rs +++ b/src/exec/mod.rs @@ -35,19 +35,17 @@ pub enum ExecutionMode { #[derive(Debug, Clone, PartialEq)] pub struct CommandSet { mode: ExecutionMode, - path_separator: Option, commands: Vec, } impl CommandSet { - pub fn new(input: I, path_separator: Option) -> Result + pub fn new(input: I) -> Result where I: IntoIterator>, S: AsRef, { Ok(CommandSet { mode: ExecutionMode::OneByOne, - path_separator, commands: input .into_iter() .map(CommandTemplate::new) @@ -55,14 +53,13 @@ impl CommandSet { }) } - pub fn new_batch(input: I, path_separator: Option) -> Result + pub fn new_batch(input: I) -> Result where I: IntoIterator>, S: AsRef, { Ok(CommandSet { mode: ExecutionMode::Batch, - path_separator, commands: input .into_iter() .map(|args| { @@ -83,8 +80,13 @@ impl CommandSet { self.mode == ExecutionMode::Batch } - pub fn execute(&self, input: &Path, out_perm: Arc>, buffer_output: bool) -> ExitCode { - let path_separator = self.path_separator.as_deref(); + pub fn execute( + &self, + input: &Path, + path_separator: Option<&str>, + out_perm: Arc>, + buffer_output: bool, + ) -> ExitCode { let commands = self .commands .iter() @@ -92,12 +94,10 @@ impl CommandSet { execute_commands(commands, &out_perm, buffer_output) } - pub fn execute_batch(&self, paths: I, limit: usize) -> ExitCode + pub fn execute_batch(&self, paths: I, limit: usize, path_separator: Option<&str>) -> ExitCode where I: Iterator, { - let path_separator = self.path_separator.as_deref(); - let builders: io::Result> = self .commands .iter() @@ -413,7 +413,7 @@ mod tests { #[test] fn tokens_with_placeholder() { assert_eq!( - CommandSet::new(vec![vec![&"echo", &"${SHELL}:"]], None).unwrap(), + CommandSet::new(vec![vec![&"echo", &"${SHELL}:"]]).unwrap(), CommandSet { commands: vec![CommandTemplate { args: vec![ @@ -423,7 +423,6 @@ mod tests { ] }], mode: ExecutionMode::OneByOne, - path_separator: None, } ); } @@ -431,7 +430,7 @@ mod tests { #[test] fn tokens_with_no_extension() { assert_eq!( - CommandSet::new(vec![vec!["echo", "{.}"]], None).unwrap(), + CommandSet::new(vec![vec!["echo", "{.}"]]).unwrap(), CommandSet { commands: vec![CommandTemplate { args: vec![ @@ -440,7 +439,6 @@ mod tests { ], }], mode: ExecutionMode::OneByOne, - path_separator: None, } ); } @@ -448,7 +446,7 @@ mod tests { #[test] fn tokens_with_basename() { assert_eq!( - CommandSet::new(vec![vec!["echo", "{/}"]], None).unwrap(), + CommandSet::new(vec![vec!["echo", "{/}"]]).unwrap(), CommandSet { commands: vec![CommandTemplate { args: vec![ @@ -457,7 +455,6 @@ mod tests { ], }], mode: ExecutionMode::OneByOne, - path_separator: None, } ); } @@ -465,7 +462,7 @@ mod tests { #[test] fn tokens_with_parent() { assert_eq!( - CommandSet::new(vec![vec!["echo", "{//}"]], None).unwrap(), + CommandSet::new(vec![vec!["echo", "{//}"]]).unwrap(), CommandSet { commands: vec![CommandTemplate { args: vec![ @@ -474,7 +471,6 @@ mod tests { ], }], mode: ExecutionMode::OneByOne, - path_separator: None, } ); } @@ -482,7 +478,7 @@ mod tests { #[test] fn tokens_with_basename_no_extension() { assert_eq!( - CommandSet::new(vec![vec!["echo", "{/.}"]], None).unwrap(), + CommandSet::new(vec![vec!["echo", "{/.}"]]).unwrap(), CommandSet { commands: vec![CommandTemplate { args: vec![ @@ -491,7 +487,6 @@ mod tests { ], }], mode: ExecutionMode::OneByOne, - path_separator: None, } ); } @@ -499,7 +494,7 @@ mod tests { #[test] fn tokens_multiple() { assert_eq!( - CommandSet::new(vec![vec!["cp", "{}", "{/.}.ext"]], None).unwrap(), + CommandSet::new(vec![vec!["cp", "{}", "{/.}.ext"]]).unwrap(), CommandSet { commands: vec![CommandTemplate { args: vec![ @@ -512,7 +507,6 @@ mod tests { ], }], mode: ExecutionMode::OneByOne, - path_separator: None, } ); } @@ -520,7 +514,7 @@ mod tests { #[test] fn tokens_single_batch() { assert_eq!( - CommandSet::new_batch(vec![vec!["echo", "{.}"]], None).unwrap(), + CommandSet::new_batch(vec![vec!["echo", "{.}"]]).unwrap(), CommandSet { commands: vec![CommandTemplate { args: vec![ @@ -529,14 +523,13 @@ mod tests { ], }], mode: ExecutionMode::Batch, - path_separator: None, } ); } #[test] fn tokens_multiple_batch() { - assert!(CommandSet::new_batch(vec![vec!["echo", "{.}", "{}"]], None).is_err()); + assert!(CommandSet::new_batch(vec![vec!["echo", "{.}", "{}"]]).is_err()); } #[test] @@ -546,7 +539,7 @@ mod tests { #[test] fn command_set_no_args() { - assert!(CommandSet::new(vec![vec!["echo"], vec![]], None).is_err()); + assert!(CommandSet::new(vec![vec!["echo"], vec![]]).is_err()); } #[test] diff --git a/src/filter/size.rs b/src/filter/size.rs index 2af20bc..a3d7c41 100644 --- a/src/filter/size.rs +++ b/src/filter/size.rs @@ -1,3 +1,4 @@ +use anyhow::anyhow; use once_cell::sync::Lazy; use regex::Regex; @@ -24,7 +25,12 @@ const GIBI: u64 = MEBI * 1024; const TEBI: u64 = GIBI * 1024; impl SizeFilter { - pub fn from_string(s: &str) -> Option { + pub fn from_string(s: &str) -> anyhow::Result { + SizeFilter::parse_opt(s) + .ok_or_else(|| anyhow!("'{}' is not a valid size constraint. See 'fd --help'.", s)) + } + + fn parse_opt(s: &str) -> Option { if !SIZE_CAPTURES.is_match(s) { return None; } @@ -165,7 +171,7 @@ mod tests { #[test] fn $name() { let i = SizeFilter::from_string($value); - assert!(i.is_none()); + assert!(i.is_err()); } )* }; diff --git a/src/main.rs b/src/main.rs index 99aee3e..a0908dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -mod app; +mod cli; mod config; mod dir_entry; mod error; @@ -12,25 +12,25 @@ mod regex_helper; mod walk; use std::env; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::time; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; use atty::Stream; +use clap::{CommandFactory,Parser}; use globset::GlobBuilder; use lscolors::LsColors; -use normpath::PathExt; use regex::bytes::{RegexBuilder, RegexSetBuilder}; +use crate::cli::{ColorWhen, Opts}; use crate::config::Config; -use crate::error::print_error; use crate::exec::CommandSet; use crate::exit_codes::ExitCode; use crate::filetypes::FileTypes; #[cfg(unix)] use crate::filter::OwnerFilter; -use crate::filter::{SizeFilter, TimeFilter}; +use crate::filter::TimeFilter; use crate::regex_helper::{pattern_has_uppercase_char, pattern_matches_strings_with_leading_dot}; // We use jemalloc for performance reasons, see https://github.com/sharkdp/fd/pull/481 @@ -67,23 +67,42 @@ fn main() { } fn run() -> Result { - let matches = app::build_app().get_matches_from(env::args_os()); + let opts = Opts::parse(); - set_working_dir(&matches)?; - let search_paths = extract_search_paths(&matches)?; - let pattern = extract_search_pattern(&matches)?; - ensure_search_pattern_is_not_a_path(&matches, pattern)?; - let pattern_regex = build_pattern_regex(&matches, pattern)?; + #[cfg(feature = "completions")] + if let Some(shell) = opts.gen_completions()? { + return print_completions(shell); + } - let config = construct_config(matches, &pattern_regex)?; + set_working_dir(&opts)?; + let search_paths = opts.search_paths()?; + if search_paths.is_empty() { + bail!("No valid search paths given."); + } + + ensure_search_pattern_is_not_a_path(&opts)?; + let pattern_regex = build_pattern_regex(&opts)?; + + let config = construct_config(opts, &pattern_regex)?; ensure_use_hidden_option_for_leading_dot_pattern(&config, &pattern_regex)?; let re = build_regex(pattern_regex, &config)?; walk::scan(&search_paths, Arc::new(re), Arc::new(config)) } -fn set_working_dir(matches: &clap::ArgMatches) -> Result<()> { - if let Some(base_directory) = matches.value_of_os("base-directory") { - let base_directory = Path::new(base_directory); +#[cfg(feature = "completions")] +#[cold] +fn print_completions(shell: clap_complete::Shell) -> Result { + // The program name is the first argument. + let program_name = env::args().next().unwrap_or_else(|| "fd".to_string()); + let mut cmd = Opts::command(); + cmd.build(); + // TODO: fix panic + clap_complete::generate(shell, &mut cmd, &program_name, &mut std::io::stdout()); + Ok(ExitCode::Success) +} + +fn set_working_dir(opts: &Opts) -> Result<()> { + if let Some(ref base_directory) = opts.base_directory { if !filesystem::is_existing_directory(base_directory) { return Err(anyhow!( "The '--base-directory' path '{}' is not a directory.", @@ -100,75 +119,11 @@ fn set_working_dir(matches: &clap::ArgMatches) -> Result<()> { Ok(()) } -fn ensure_current_directory_exists(current_directory: &Path) -> Result<()> { - if filesystem::is_existing_directory(current_directory) { - Ok(()) - } else { - Err(anyhow!( - "Could not retrieve current directory (has it been deleted?)." - )) - } -} - -fn extract_search_pattern(matches: &clap::ArgMatches) -> Result<&'_ str> { - let pattern = matches - .value_of_os("pattern") - .map(|p| { - p.to_str() - .ok_or_else(|| anyhow!("The search pattern includes invalid UTF-8 sequences.")) - }) - .transpose()? - .unwrap_or(""); - Ok(pattern) -} - -fn extract_search_paths(matches: &clap::ArgMatches) -> Result> { - let parameter_paths = matches - .values_of_os("path") - .or_else(|| matches.values_of_os("search-path")); - - let mut search_paths = match parameter_paths { - Some(paths) => paths - .filter_map(|path| { - let path_buffer = PathBuf::from(path); - if filesystem::is_existing_directory(&path_buffer) { - Some(path_buffer) - } else { - print_error(format!( - "Search path '{}' is not a directory.", - path_buffer.to_string_lossy(), - )); - None - } - }) - .collect(), - None => { - let current_directory = Path::new("."); - ensure_current_directory_exists(current_directory)?; - vec![current_directory.to_path_buf()] - } - }; - - if search_paths.is_empty() { - return Err(anyhow!("No valid search paths given.")); - } - if matches.is_present("absolute-path") { - update_to_absolute_paths(&mut search_paths); - } - Ok(search_paths) -} - -fn update_to_absolute_paths(search_paths: &mut [PathBuf]) { - for buffer in search_paths.iter_mut() { - *buffer = filesystem::absolute_path(buffer.normalize().unwrap().as_path()).unwrap(); - } -} - /// Detect if the user accidentally supplied a path instead of a search pattern -fn ensure_search_pattern_is_not_a_path(matches: &clap::ArgMatches, pattern: &str) -> Result<()> { - if !matches.is_present("full-path") - && pattern.contains(std::path::MAIN_SEPARATOR) - && Path::new(pattern).is_dir() +fn ensure_search_pattern_is_not_a_path(opts: &Opts) -> Result<()> { + if !opts.full_path + && opts.pattern.contains(std::path::MAIN_SEPARATOR) + && Path::new(&opts.pattern).is_dir() { Err(anyhow!( "The search pattern '{pattern}' contains a path-separation character ('{sep}') \ @@ -177,7 +132,7 @@ fn ensure_search_pattern_is_not_a_path(matches: &clap::ArgMatches, pattern: &str fd . '{pattern}'\n\n\ Instead, if you want your pattern to match the full file path, use:\n\n \ fd --full-path '{pattern}'", - pattern = pattern, + pattern = &opts.pattern, sep = std::path::MAIN_SEPARATOR, )) } else { @@ -185,11 +140,12 @@ fn ensure_search_pattern_is_not_a_path(matches: &clap::ArgMatches, pattern: &str } } -fn build_pattern_regex(matches: &clap::ArgMatches, pattern: &str) -> Result { - Ok(if matches.is_present("glob") && !pattern.is_empty() { +fn build_pattern_regex(opts: &Opts) -> Result { + let pattern = &opts.pattern; + Ok(if opts.glob && !pattern.is_empty() { let glob = GlobBuilder::new(pattern).literal_separator(true).build()?; glob.regex().to_owned() - } else if matches.is_present("fixed-strings") { + } else if opts.fixed_strings { // Treat pattern as literal string if '--fixed-strings' is used regex::escape(pattern) } else { @@ -211,28 +167,25 @@ fn check_path_separator_length(path_separator: Option<&str>) -> Result<()> { } } -fn construct_config(matches: clap::ArgMatches, pattern_regex: &str) -> Result { +fn construct_config(mut opts: Opts, pattern_regex: &str) -> Result { // The search will be case-sensitive if the command line flag is set or // if the pattern has an uppercase character (smart case). - let case_sensitive = !matches.is_present("ignore-case") - && (matches.is_present("case-sensitive") || pattern_has_uppercase_char(pattern_regex)); + let case_sensitive = + !opts.ignore_case && (opts.case_sensitive || pattern_has_uppercase_char(pattern_regex)); - let path_separator = matches - .value_of("path-separator") - .map_or_else(filesystem::default_path_separator, |s| Some(s.to_owned())); + let path_separator = opts + .path_separator + .take() + .or_else(filesystem::default_path_separator); let actual_path_separator = path_separator .clone() .unwrap_or_else(|| std::path::MAIN_SEPARATOR.to_string()); check_path_separator_length(path_separator.as_deref())?; - let size_limits = extract_size_limits(&matches)?; - let time_constraints = extract_time_constraints(&matches)?; + let size_limits = std::mem::replace(&mut opts.size, vec![]); + let time_constraints = extract_time_constraints(&opts)?; #[cfg(unix)] - let owner_constraint = matches - .value_of("owner") - .map(OwnerFilter::from_string) - .transpose()? - .flatten(); + let owner_constraint: Option = opts.owner; #[cfg(windows)] let ansi_colors_support = @@ -241,10 +194,12 @@ fn construct_config(matches: clap::ArgMatches, pattern_regex: &str) -> Result true, - Some("never") => false, - _ => ansi_colors_support && env::var_os("NO_COLOR").is_none() && interactive_terminal, + let colored_output = match opts.color { + ColorWhen::Always => true, + ColorWhen::Never => false, + ColorWhen::Auto => { + ansi_colors_support && env::var_os("NO_COLOR").is_none() && interactive_terminal + } }; let ls_colors = if colored_output { @@ -252,80 +207,42 @@ fn construct_config(matches: clap::ArgMatches, pattern_regex: &str) -> Result()) - .transpose() - .context("Failed to parse argument to --max-depth/--exact-depth")?, - min_depth: matches - .value_of("min-depth") - .or_else(|| matches.value_of("exact-depth")) - .map(|n| n.parse::()) - .transpose() - .context("Failed to parse argument to --min-depth/--exact-depth")?, - prune: matches.is_present("prune"), - threads: std::cmp::max( - matches - .value_of("threads") - .map(|n| n.parse::()) - .transpose() - .context("Failed to parse number of threads")? - .map(|n| { - if n > 0 { - Ok(n) - } else { - Err(anyhow!("Number of threads must be positive.")) - } - }) - .transpose()? - .unwrap_or_else(num_cpus::get), - 1, - ), - max_buffer_time: matches - .value_of("max-buffer-time") - .map(|n| n.parse::()) - .transpose() - .context("Failed to parse max. buffer time argument")? - .map(time::Duration::from_millis), + search_full_path: opts.full_path, + ignore_hidden: !(opts.hidden || opts.rg_alias_ignore()), + read_fdignore: !(opts.no_ignore || opts.rg_alias_ignore()), + read_vcsignore: !(opts.no_ignore || opts.rg_alias_ignore() || opts.no_ignore_vcs), + read_parent_ignore: !opts.no_ignore_parent, + read_global_ignore: !opts.no_ignore || opts.rg_alias_ignore() || opts.no_global_ignore_file, + follow_links: opts.follow, + one_file_system: opts.one_file_system, + null_separator: opts.null_separator, + quiet: opts.quiet, + max_depth: opts.max_depth(), + min_depth: opts.min_depth(), + prune: opts.prune, + threads: opts.threads(), + max_buffer_time: opts.max_buffer_time, ls_colors, interactive_terminal, - file_types: matches.values_of("file-type").map(|values| { + file_types: opts.filetype.as_ref().map(|values| { + use crate::cli::FileType::*; let mut file_types = FileTypes::default(); for value in values { match value { - "f" | "file" => file_types.files = true, - "d" | "directory" => file_types.directories = true, - "l" | "symlink" => file_types.symlinks = true, - "x" | "executable" => { + File => file_types.files = true, + Directory => file_types.directories = true, + Symlink => file_types.symlinks = true, + Executable => { file_types.executables_only = true; file_types.files = true; } - "e" | "empty" => file_types.empty_only = true, - "s" | "socket" => file_types.sockets = true, - "p" | "pipe" => file_types.pipes = true, - _ => unreachable!(), + Empty => file_types.empty_only = true, + Socket => file_types.sockets = true, + Pipe => file_types.pipes = true, } } @@ -337,10 +254,12 @@ fn construct_config(matches: clap::ArgMatches, pattern_regex: &str) -> Result Result()) - .transpose() - .context("Failed to parse --batch-size argument")? - .unwrap_or_default(), - exclude_patterns: matches - .values_of("exclude") - .map(|v| v.map(|p| String::from("!") + p).collect()) - .unwrap_or_else(Vec::new), - ignore_files: matches - .values_of("ignore-file") - .map(|vs| vs.map(PathBuf::from).collect()) - .unwrap_or_else(Vec::new), + batch_size: opts.batch_size, + exclude_patterns: opts.exclude.iter().map(|p| String::from("!") + p).collect(), + ignore_files: std::mem::replace(&mut opts.ignore_file, vec![]), size_constraints: size_limits, time_constraints, #[cfg(unix)] owner_constraint, - show_filesystem_errors: matches.is_present("show-errors"), + show_filesystem_errors: opts.show_errors, path_separator, actual_path_separator, - max_results: matches - .value_of("max-results") - .map(|n| n.parse::()) - .transpose() - .context("Failed to parse --max-results argument")? - .filter(|&n| n > 0) - .or_else(|| { - if matches.is_present("max-one-result") { - Some(1) - } else { - None - } - }), - strip_cwd_prefix: (!matches.is_present("path") - && !matches.is_present("search-path") - && (interactive_terminal || matches.is_present("strip-cwd-prefix"))), + max_results: opts.max_results(), + strip_cwd_prefix: (opts.no_search_paths() + && (interactive_terminal || opts.strip_cwd_prefix)), }) } -fn extract_command( - matches: &clap::ArgMatches, - path_separator: Option<&str>, - colored_output: bool, -) -> Result> { - None.or_else(|| { - matches - .grouped_values_of("exec") - .map(|args| CommandSet::new(args, path_separator.map(str::to_string))) - }) - .or_else(|| { - matches - .grouped_values_of("exec-batch") - .map(|args| CommandSet::new_batch(args, path_separator.map(str::to_string))) - }) - .or_else(|| { - if !matches.is_present("list-details") { - return None; - } +fn extract_command(opts: &mut Opts, colored_output: bool) -> Result> { + opts.exec + .command + .take() + .map(Ok) + .or_else(|| { + if !opts.list_details { + return None; + } + let color_arg = format!("--color={:?}", opts.color); - let color = matches.value_of("color").unwrap_or("auto"); - let color_arg = format!("--color={}", color); - - let res = determine_ls_command(&color_arg, colored_output) - .map(|cmd| CommandSet::new_batch([cmd], path_separator.map(str::to_string)).unwrap()); - - Some(res) - }) - .transpose() + let res = determine_ls_command(&color_arg, colored_output) + .map(|cmd| CommandSet::new_batch([cmd]).unwrap()); + Some(res) + }) + .transpose() } fn determine_ls_command(color_arg: &str, colored_output: bool) -> Result> { @@ -499,20 +381,10 @@ fn determine_ls_command(color_arg: &str, colored_output: bool) -> Result Result> { - matches.values_of("size").map_or(Ok(Vec::new()), |vs| { - vs.map(|sf| { - SizeFilter::from_string(sf) - .ok_or_else(|| anyhow!("'{}' is not a valid size constraint. See 'fd --help'.", sf)) - }) - .collect::>>() - }) -} - -fn extract_time_constraints(matches: &clap::ArgMatches) -> Result> { +fn extract_time_constraints(opts: &Opts) -> Result> { let now = time::SystemTime::now(); let mut time_constraints: Vec = Vec::new(); - if let Some(t) = matches.value_of("changed-within") { + if let Some(ref t) = opts.changed_within { if let Some(f) = TimeFilter::after(&now, t) { time_constraints.push(f); } else { @@ -522,7 +394,7 @@ fn extract_time_constraints(matches: &clap::ArgMatches) -> Result