diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 718fc19..5d5c363 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1,7 +1,7 @@ name: CICD env: - MIN_SUPPORTED_RUST_VERSION: "1.57.0" + MIN_SUPPORTED_RUST_VERSION: "1.60.0" CICD_INTERMEDIATES_DIR: "_cicd-intermediates" on: @@ -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 0b21f8e..80c9b24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,35 +113,47 @@ dependencies = [ [[package]] name = "clap" -version = "3.2.22" +version = "4.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750" +checksum = "335867764ed2de42325fafe6d18b8af74ba97ee0c590fa016f157535b42ab04b" dependencies = [ "atty", "bitflags", + "clap_derive", "clap_lex", - "indexmap", "once_cell", "strsim", "termcolor", - "terminal_size 0.2.1", - "textwrap", + "terminal_size", ] [[package]] name = "clap_complete" -version = "3.2.5" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f7a2e0a962c45ce25afce14220bc24f9dade0a1787f185cecf96bfba7847cd8" +checksum = "dfe581a2035db4174cdbdc91265e1aba50f381577f0510d0ad36c7bc59cc84a3" dependencies = [ "clap", ] [[package]] -name = "clap_lex" -version = "0.2.4" +name = "clap_derive" +version = "4.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +checksum = "16a1b0f6422af32d5da0c58e2703320f379216ee70198241c84173a8c5ac28f3" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" dependencies = [ "os_str_bytes", ] @@ -384,10 +396,10 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "heck" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" [[package]] name = "hermit-abi" @@ -446,16 +458,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "indexmap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" -dependencies = [ - "autocfg", - "hashbrown", -] - [[package]] name = "io-lifetimes" version = "0.7.4" @@ -806,16 +808,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "terminal_size" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "terminal_size" version = "0.2.1" @@ -848,15 +840,6 @@ dependencies = [ "syn", ] -[[package]] -name = "textwrap" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" -dependencies = [ - "terminal_size 0.1.17", -] - [[package]] name = "thiserror" version = "1.0.37" diff --git a/Cargo.toml b/Cargo.toml index fad68d5..a75a005 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,6 @@ name = "fd" path = "src/main.rs" [build-dependencies] -clap = { version = "3.1", features = ["cargo"] } -clap_complete = "3.1" version_check = "0.9" [dependencies] @@ -52,10 +50,11 @@ normpath = "0.3.2" chrono = "0.4" once_cell = "1.15.0" crossbeam-channel = "0.5.6" +clap_complete = {version = "4.0", optional = true} [dependencies.clap] -version = "3.1" -features = ["suggestions", "color", "wrap_help", "cargo", "unstable-grouped"] +version = "4.0.12" +features = ["suggestions", "color", "wrap_help", "cargo", "unstable-grouped", "derive"] [target.'cfg(unix)'.dependencies] users = "0.11.0" @@ -85,4 +84,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/build.rs b/build.rs index 771f932..224f2d3 100644 --- a/build.rs +++ b/build.rs @@ -1,13 +1,5 @@ -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.57"; + let min_version = "1.60"; match version_check::is_min_version(min_version) { Some(true) => {} @@ -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/clippy.toml b/clippy.toml index 23b32c1..16caf02 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1 @@ -msrv = "1.57.0" +msrv = "1.60.0" diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index fa9416a..0000000 --- a/src/app.rs +++ /dev/null @@ -1,779 +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.", - ) - .after_long_help( - "Bugs can be reported on GitHub: https://github.com/sharkdp/fd/issues" - ) - .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 -0/--print0 output") - .long_help( - "By default, relative paths are prefixed with './' when -x/--exec, \ - -X/--exec-batch, or -0/--print0 are given, to reduce the risk of a \ - path starting with '-' being treated as a command line option. 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..fa69fba --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,845 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[cfg(feature = "completions")] +use anyhow::anyhow; +use clap::{ + error::ErrorKind, value_parser, Arg, ArgAction, ArgGroup, ArgMatches, Command, Parser, + ValueEnum, +}; +#[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::error::Result { + Ok(Negations) + } + + fn update_from_arg_matches(&mut self, _: &ArgMatches) -> clap::error::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") + .action(ArgAction::Count) + .long("no-hidden") + .overrides_with("hidden") + .hide(true) + .long_help("Overrides --hidden."), + ) + .arg( + Arg::new("ignore") + .action(ArgAction::Count) + .long("ignore") + .overrides_with("no_ignore") + .hide(true) + .long_help("Overrides --no-ignore."), + ) + .arg( + Arg::new("ignore_vcs") + .action(ArgAction::Count) + .long("ignore-vcs") + .overrides_with("no_ignore_vcs") + .hide(true) + .long_help("Overrides --no-ignore-vcs."), + ) + .arg( + Arg::new("relative_path") + .action(ArgAction::Count) + .long("relative-path") + .overrides_with("absolute_path") + .hide(true) + .long_help("Overrides --absolute-path."), + ) + .arg( + Arg::new("no_follow") + .action(ArgAction::Count) + .long("no-follow") + .overrides_with("follow") + .hide(true) + .long_help("Overrides --follow."), + ) + } +} + +#[derive(Parser)] +#[command( + version, + after_long_help = "Bugs can be reported on GitHub: https://github.com/sharkdp/fd/issues", + args_override_self = true, + 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 + #[arg( + long, + short = 'H', + 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." + )] + pub hidden: bool, + + /// Do not respect .(git|fd)ignore files + #[arg( + long, + short = 'I', + 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." + )] + pub no_ignore: bool, + + /// Do not respect .gitignore files + #[arg( + long, + hide_short_help = true, + 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." + )] + pub no_ignore_vcs: bool, + + /// Do not respect .(git|fd)ignore files in parent directories + #[arg( + long, + hide_short_help = true, + long_help = "Show search results from files and directories that would otherwise be \ + ignored by '.gitignore', '.ignore', or '.fdignore' files in parent directories." + )] + pub no_ignore_parent: bool, + + /// Do not respect the global ignore file + #[arg(long, hide = true)] + pub no_global_ignore_file: bool, + + /// Unrestricted search, alias for '--no-ignore --hidden' + #[arg(long = "unrestricted", short = 'u', overrides_with_all(&["ignore", "no_hidden"]), action(ArgAction::Count), hide_short_help = true, + long_help = "Perform an unrestricted search, including ignored and hidden files. This is \ + an alias for '--no-ignore --hidden'." + )] + rg_alias_hidden_ignore: u8, + + /// Case-sensitive search (default: smart case) + #[arg( + long, + short = 's', + overrides_with("ignore_case"), + long_help = "Perform a case-sensitive search. By default, fd uses case-insensitive \ + searches, unless the pattern contains an uppercase character (smart \ + case)." + )] + pub case_sensitive: bool, + + /// Case-insensitive search (default: smart case) + #[arg( + long, + short = 'i', + overrides_with("case_sensitive"), + long_help = "Perform a case-insensitive search. By default, fd uses case-insensitive \ + searches, unless the pattern contains an uppercase character (smart \ + case)." + )] + pub ignore_case: bool, + + /// Glob-based search (default: regular expression) + #[arg( + long, + short = 'g', + conflicts_with("fixed_strings"), + long_help = "Perform a glob-based search instead of a regular expression search." + )] + pub glob: bool, + + /// Regular-expression based search (default) + #[arg( + long, + overrides_with("glob"), + hide_short_help = true, + long_help = "Perform a regular-expression based search (default). This can be used to \ + override --glob." + )] + pub regex: bool, + + /// Treat pattern as literal string stead of regex + #[arg( + long, + short = 'F', + alias = "literal", + hide_short_help = true, + 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'." + )] + pub fixed_strings: bool, + + /// Show absolute instead of relative paths + #[arg( + long, + short = 'a', + long_help = "Shows the full path starting from the root as opposed to relative paths. \ + The flag can be overridden with --relative-path." + )] + pub absolute_path: bool, + + /// Use a long listing format with file metadata + #[arg( + long, + short = 'l', + conflicts_with("absolute_path"), + 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." + )] + pub list_details: bool, + + /// Follow symbolic links + #[arg( + long, + short = 'L', + alias = "dereference", + 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." + )] + pub follow: bool, + + /// Search full abs. path (default: filename only) + #[arg( + long, + short = 'p', + 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'" + )] + pub full_path: bool, + + /// Separate search results by the null character + #[arg( + long = "print0", + short = '0', + conflicts_with("list_details"), + hide_short_help = true, + long_help = "Separate search results by the null character (instead of newlines). \ + Useful for piping results to 'xargs'." + )] + pub null_separator: bool, + + /// Set maximum search depth (default: none) + #[arg( + long, + short = 'd', + value_name = "depth", + alias("maxdepth"), + long_help = "Limit the directory traversal to a given depth. By default, there is no \ + limit on the search depth." + )] + max_depth: Option, + + /// Only show search results starting at the given depth. + #[arg( + long, + value_name = "depth", + hide_short_help = true, + long_help = "Only show search results starting at the given depth. \ + See also: '--max-depth' and '--exact-depth'" + )] + min_depth: Option, + + /// Only show search results at the exact given depth + #[arg(long, value_name = "depth", hide_short_help = true, conflicts_with_all(&["max_depth", "min_depth"]), + long_help = "Only show search results at the exact given depth. This is an alias for \ + '--min-depth --max-depth '.", + )] + exact_depth: Option, + + /// Do not traverse into directories that match the search criteria. If + /// you want to exclude specific directories, use the '--exclude=…' option. + #[arg(long, hide_short_help = true, conflicts_with_all(&["size", "exact_depth"]), + long_help = "Do not traverse into directories that match the search criteria. If \ + you want to exclude specific directories, use the '--exclude=…' option.", + )] + pub prune: bool, + + /// Filter by type: file (f), directory (d), symlink (l), + /// executable (x), empty (e), socket (s), pipe (p) + #[arg( + long = "type", + short = 't', + value_name = "filetype", + hide_possible_values = true, + value_enum, + 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" + )] + pub filetype: Option>, + + /// Filter by file extension + #[arg( + long = "extension", + short = 'e', + value_name = "ext", + 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." + )] + pub extensions: Option>, + + #[command(flatten)] + pub exec: Exec, + + /// Max number of arguments to run as a batch size with -X + #[arg( + long, + value_name = "size", + hide_short_help = true, + requires("exec_batch"), + value_parser = value_parser!(usize), + default_value_t, + 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.", + )] + pub batch_size: usize, + + /// Exclude entries that match the given glob pattern + #[arg( + long, + short = 'E', + value_name = "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" + )] + pub exclude: Vec, + + /// Add a custom ignore-file in '.gitignore' format + #[arg( + long, + value_name = "path", + hide_short_help = true, + long_help = "Add a custom ignore-file in '.gitignore' format. These files have a low precedence." + )] + pub ignore_file: Vec, + + /// When to use colors + #[arg( + long, + short = 'c', + value_enum, + default_value_t = ColorWhen::Auto, + value_name = "when", + long_help = "Declare when to use color for the pattern match output", + )] + pub color: ColorWhen, + + /// Set number of threads to use for searching & executing (default: number + /// of available CPU cores) + #[arg(long, short = 'j', value_name = "num", hide_short_help = true, value_parser = 1..)] + pub threads: Option, + + /// Limit results based on the size of files + #[arg(long, short = 'S', value_parser = SizeFilter::from_string, allow_hyphen_values = true, verbatim_doc_comment, value_name = "size", + 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", + )] + 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. + #[arg(long, hide = true, value_parser = parse_millis)] + pub max_buffer_time: Option, + + /// Filter by file modification time (newer than) + #[arg( + long, + alias("change-newer-than"), + alias("newer"), + value_name = "date|dur", + 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" + )] + pub changed_within: Option, + + /// Filter by file modification time (older than) + #[arg( + long, + alias("change-older-than"), + alias("older"), + value_name = "date|dur", + 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" + )] + pub changed_before: Option, + + /// Limit number of search results + #[arg( + long, + value_name = "count", + hide_short_help = true, + long_help = "Limit the number of search results to 'count' and quit immediately." + )] + max_results: Option, + + /// Limit search to a single result + #[arg( + short = '1', + hide_short_help = true, + overrides_with("max_results"), + long_help = "Limit the search to a single result and quit immediately. \ + This is an alias for '--max-results=1'." + )] + max_one_result: bool, + + /// Print nothing, exit code 0 if match found, 1 otherwise + #[arg( + long, + short = 'q', + alias = "has-results", + hide_short_help = true, + conflicts_with("max_results"), + 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." + )] + pub quiet: bool, + + /// Show filesystem errors + #[arg( + long, + hide_short_help = true, + long_help = "Enable the display of filesystem errors for situations such as \ + insufficient permissions or dead symlinks." + )] + pub show_errors: bool, + + /// Change current working directory + #[arg( + long, + value_name = "path", + hide_short_help = true, + 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." + )] + pub base_directory: Option, + + /// the search pattern (a regular expression, unless '--glob' is used; optional) + #[arg( + default_value = "", + hide_default_value = true, + value_name = "pattern", + 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')." + )] + pub pattern: String, + + /// Set path separator when printing file paths + #[arg( + long, + value_name = "separator", + hide_short_help = true, + long_help = "Set the path separator to use when printing file paths. The default is \ + the OS-specific separator ('/' on Unix, '\\' on Windows)." + )] + pub path_separator: Option, + + /// the root directories for the filesystem search (optional) + #[arg(action = ArgAction::Append, + value_name = "path", + long_help = "The directory where the filesystem search is rooted (optional). If \ + omitted, search the current working directory.", + )] + path: Vec, + + /// Provides paths to search as an alternative to the positional argument + #[arg( + long, + conflicts_with("path"), + value_name = "search-path", + hide_short_help = true, + long_help = "Provide paths to search as an alternative to the positional \ + argument. Changes the usage to `fd [OPTIONS] --search-path \ + --search-path []`" + )] + search_path: Vec, + + /// By default, relative paths are prefixed with './' when -x/--exec, + /// -X/--exec-batch, or -0/--print0 are given, to reduce the risk of a + /// path starting with '-' being treated as a command line option. Use + /// this flag to disable this behaviour. + #[arg(long, conflicts_with_all(&["path", "search_path"]), hide_short_help = true, + long_help = "By default, relative paths are prefixed with './' when -x/--exec, \ + -X/--exec-batch, or -0/--print0 are given, to reduce the risk of a \ + path starting with '-' being treated as a command line option. Use \ + this flag to disable this behaviour.", + )] + pub strip_cwd_prefix: bool, + + /// Filter by owning user and/or group + #[cfg(unix)] + #[arg(long, short = 'o', value_parser = OwnerFilter::from_string, value_name = "user: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'" + )] + pub owner: Option, + #[cfg(any(unix, windows))] + #[arg(long, aliases(&["mount", "xdev"]), hide_short_help = true, + 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).")] + pub one_file_system: bool, + + #[cfg(feature = "completions")] + #[arg(long, 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 { + // This will panic if the number of threads passed in is more than usize::MAX in an environment + // where usize is less than 32 bits (for example 16-bit architectures). It's pretty + // unlikely fd will be running in such an environment, and even more unlikely someone would + // be trying to use that many threads on such an environment, so I think panicing is an + // appropriate way to handle that. + std::cmp::max( + self.threads + .map_or_else(num_cpus::get, |n| n.try_into().expect("too many threads")), + 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() + } +} + +#[cfg(feature = "completions")] +fn guess_shell() -> anyhow::Result { + let env_shell = std::env::var_os("SHELL").map(PathBuf::from); + if let Some(shell) = env_shell + .as_ref() + .and_then(|s| s.file_name()) + .and_then(|s| s.to_str()) + { + shell + .parse::() + .map_err(|_| anyhow!("Unknown shell {}", shell)) + } else { + // Assume powershell on windows + #[cfg(windows)] + return Ok(Shell::PowerShell); + #[cfg(not(windows))] + return Err(anyhow!("Unable to get shell from environment")); + } +} + +#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] +pub enum FileType { + #[value(alias = "f")] + File, + #[value(alias = "d")] + Directory, + #[value(alias = "l")] + Symlink, + #[value(alias = "x")] + Executable, + #[value(alias = "e")] + Empty, + #[value(alias = "s")] + Socket, + #[value(alias = "p")] + Pipe, +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug, ValueEnum)] +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, +} + +impl ColorWhen { + pub fn as_str(&self) -> &'static str { + use ColorWhen::*; + match *self { + Auto => "auto", + Never => "never", + Always => "always", + } + } +} + +// 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::error::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::error::Result<()> { + *self = Self::from_arg_matches(matches)?; + Ok(()) + } +} + +impl clap::Args for Exec { + fn augment_args(cmd: Command) -> Command { + cmd.arg(Arg::new("exec") + .action(ArgAction::Append) + .long("exec") + .short('x') + .num_args(1..) + .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") + .action(ArgAction::Append) + .long("exec-batch") + .short('X') + .num_args(1..) + .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 1617ea7..b2ea0a4 100644 --- a/src/exec/job.rs +++ b/src/exec/job.rs @@ -40,6 +40,7 @@ pub fn job( // Generate a command, execute it and store its exit code. results.push(cmd.execute( dir_entry.stripped_path(config), + config.path_separator.as_deref(), Arc::clone(&out_perm), buffer_output, )) @@ -61,5 +62,5 @@ pub fn batch(rx: Receiver, cmd: &CommandSet, config: &Config) -> E } }); - cmd.execute_batch(paths, config.batch_size) + cmd.execute_batch(paths, config.batch_size, config.path_separator.as_deref()) } diff --git a/src/exec/mod.rs b/src/exec/mod.rs index da83223..bcc1690 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 d8ab4e8..5df60ab 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 ece5b4d..9888b02 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::take(&mut opts.size); + 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,43 @@ 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 +255,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::take(&mut opts.ignore_file), 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") - && (matches.is_present("strip-cwd-prefix") - || !(matches.is_present("null_separator") - || matches.is_present("exec") - || matches.is_present("exec-batch"))), + max_results: opts.max_results(), + strip_cwd_prefix: (opts.no_search_paths() + && (opts.strip_cwd_prefix || !(opts.null_separator || has_command))), }) } -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.as_str()); - 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> { @@ -502,20 +382,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 { @@ -525,7 +395,7 @@ fn extract_time_constraints(matches: &clap::ArgMatches) -> Result