diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 58eb9f2..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug Report -about: Report a bug. -title: "" -labels: bug -assignees: '' - ---- - -**Describe the bug you encountered:** - - - - -**Describe what you expected to happen:** - - -**What version of `fd` are you using?** - - -**Which operating system / distribution are you on?** - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..778408d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,41 @@ +name: Bug Report +description: Report a bug. +title: "[BUG] " +labels: bug +body: + - type: markdown + attributes: + value: | + Please check out the [troubleshooting section](https://github.com/sharkdp/fd#troubleshooting) first. + - type: checkboxes + attributes: + options: + - label: I have read the troubleshooting section and still think this is a bug. + required: true + - type: textarea + id: bug + attributes: + label: "Describe the bug you encountered:" + validations: + required: true + - type: textarea + id: expected + attributes: + label: "Describe what you expected to happen:" + - type: input + id: version + attributes: + label: "What version of `fd` are you using?" + placeholder: "paste the output of `fd --version` here" + validations: + required: true + - type: textarea + id: os + attributes: + label: Which operating system / distribution are you on? + placeholder: | + Unix: paste the output of `uname -srm` and lsb_release -a` here. + Windows: please tell us your Windows version + render: shell + validations: + required: true diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 3bf1da4..03d59a2 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1,7 +1,7 @@ name: CICD env: - MIN_SUPPORTED_RUST_VERSION: "1.42.0" + MIN_SUPPORTED_RUST_VERSION: "1.53.0" CICD_INTERMEDIATES_DIR: "_cicd-intermediates" on: @@ -14,17 +14,16 @@ on: - '*' jobs: - min_version: - name: Minimum supported rust version + code_quality: + name: Code quality runs-on: ubuntu-20.04 steps: - name: Checkout source code uses: actions/checkout@v2 - - - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }}) + - name: Install rust toolchain uses: actions-rs/toolchain@v1 with: - toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }} + toolchain: stable default: true profile: minimal # minimal component installation (ie, no documentation) components: clippy, rustfmt @@ -33,11 +32,26 @@ jobs: with: command: fmt args: -- --check - - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix) + - name: Ensure MSRV is set in `clippy.toml` + run: grep "^msrv = \"${{ env.MIN_SUPPORTED_RUST_VERSION }}\"\$" clippy.toml + - name: Run clippy uses: actions-rs/cargo@v1 with: command: clippy args: --locked --all-targets --all-features + + min_version: + name: Minimum supported rust version + runs-on: ubuntu-20.04 + steps: + - name: Checkout source code + uses: actions/checkout@v2 + - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }}) + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }} + default: true + profile: minimal # minimal component installation (ie, no documentation) - name: Run tests uses: actions-rs/cargo@v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbeead..bb6f8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,19 @@ # Upcoming release +## Performance improvements + +- File metadata is now cached between the different filters that require it (e.g. `--owner`, + `--size`), reducing the number of `stat` syscalls when multiple filters are used; see #863 + ## Features - Don't buffer command output from `--exec` when using a single thread. See #522 - Add new `-q, --quiet` flag, see #303 (@Asha20) +- Add new `--no-ignore-parent` flag, see #787 (@will459) + +- Add new `--batch-size` flag, see #410 (@devonhollowood) + - Add opposing command-line options, see #595 (@Asha20) ## Bugfixes @@ -15,6 +24,8 @@ - Properly handle write errors to devices that are full, see #737 - Use local time zone for time functions (`--change-newer-than`, `--change-older-than`), see #631 (@jacobmischka) - Support `--list-details` on more platforms (like BusyBox), see #783 +- The filters `--owner`, `--size`, and `--changed-{within,before}` now apply to symbolic links + themselves, rather than the link target, except when `--follow` is specified; see #863 ## Changes diff --git a/Cargo.lock b/Cargo.lock index 9d642c0..5c4918b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,9 +31,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.42" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595d3cfa7a60d4555cb5067b99f07142a08ea778de5cf993f7b75c7d8fabc486" +checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" [[package]] name = "atty" @@ -54,24 +54,24 @@ checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" [[package]] name = "bitflags" -version = "1.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bstr" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" dependencies = [ "memchr", ] [[package]] name = "cc" -version = "1.0.69" +version = "1.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70cc2f62c6ce1868963827bd677764c62d07c3d9a3e1fb1177ee1a9ab199eb2" +checksum = "79c2681d6594606957bbb8631c4b90a7fcaaa72cdb714743a437b156d6a7eedd" [[package]] name = "cfg-if" @@ -120,9 +120,9 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "377c9b002a72a0b2c1a18c62e2f3864bdfea4a015e3683a96e24aa45dd6c02d1" +checksum = "a19c6cedffdc8c03a3346d723eb20bd85a13362bb96dc2ac000842c6381ec7bf" dependencies = [ "nix", "winapi", @@ -177,6 +177,7 @@ dependencies = [ "lscolors", "normpath", "num_cpus", + "once_cell", "regex", "regex-syntax", "tempdir", @@ -301,9 +302,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.99" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765" +checksum = "a60553f9a9e039a333b4e9b20573b9e9b9c0bb3a11e201ccc48ef4283456d673" [[package]] name = "log" @@ -316,18 +317,18 @@ dependencies = [ [[package]] name = "lscolors" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24b894c45c9da468621cdd615a5a79ee5e5523dd4f75c76ebc03d458940c16e" +checksum = "bd0aa49b10c47f9a4391a99198b5e65c74f9ca771c0dcc856bb75a3f46c8627d" dependencies = [ "ansi_term 0.12.1", ] [[package]] name = "memchr" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" [[package]] name = "memoffset" @@ -340,9 +341,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1e25ee6b412c2a1e3fcb6a4499a5c1bfe7f43e014bdce9a6b6666e5aa2d187" +checksum = "f305c2c2e4c39a82f7bf0bf65fb557f9070ce06781d4f2454295cc34b1c43188" dependencies = [ "bitflags", "cc", @@ -353,9 +354,9 @@ dependencies = [ [[package]] name = "normpath" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27e6e8f70e9fbbe3752d330d769e3424f24b9458ce266df93a3b456902fd696a" +checksum = "640c20e9df4a2d4a5adad5b47e17d76dac3e824346b181931c3ec9f7a85687b1" dependencies = [ "winapi", ] @@ -397,18 +398,18 @@ checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" [[package]] name = "proc-macro2" -version = "1.0.28" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" +checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" dependencies = [ "unicode-xid", ] [[package]] name = "quote" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" dependencies = [ "proc-macro2", ] @@ -512,9 +513,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] name = "syn" -version = "1.0.74" +version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" +checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" dependencies = [ "proc-macro2", "quote", @@ -543,9 +544,9 @@ dependencies = [ [[package]] name = "test-case" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b114ece25254e97bf48dd4bfc2a12bad0647adacfe4cae1247a9ca6ad302cec" +checksum = "c7cad0a06f9a61e94355aa3b3dc92d85ab9c83406722b1ca5e918d4297c12c23" dependencies = [ "cfg-if", "proc-macro2", @@ -585,9 +586,9 @@ dependencies = [ [[package]] name = "unicode-width" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index 865525c..c89b2e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,17 +41,18 @@ lazy_static = "1.1.0" num_cpus = "1.8" regex = "1.5.4" regex-syntax = "0.6" -ctrlc = "3.1" -humantime = "2.0" -lscolors = "0.7" +ctrlc = "3.2" +humantime = "2.1" +lscolors = "0.8" globset = "0.4" anyhow = "1.0" dirs-next = "2.0" normpath = "0.3" chrono = "0.4" +once_cell = "1.8.0" [dependencies.clap] -version = "2.31.2" +version = "2.31.3" features = ["suggestions", "color", "wrap_help"] [target.'cfg(unix)'.dependencies] @@ -69,8 +70,8 @@ jemallocator = "0.3.0" [dev-dependencies] diff = "0.1" tempdir = "0.3" -filetime = "0.2.14" -test-case = "1.2.0" +filetime = "0.2" +test-case = "1.2" [profile.release] lto = true diff --git a/README.md b/README.md index 22361cb..e0fc261 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,14 @@ use a character class with a single hyphen character: > fd '[-]pattern' ``` +### "Command not found" for `alias`es or shell functions + +Shell `alias`es and shell functions can not be used for command execution via `fd -x` or +`fd -X`. In `zsh`, you can make the alias global via `alias -g myalias="…"`. In `bash`, +you can use `export -f my_function` to make available to child processes. You would still +need to call `fd -x bash -c 'my_function "$1"' bash`. For other use cases or shells, use +a (temporary) shell script. + ## Integration with other programs ### Using fd with `fzf` @@ -625,7 +633,7 @@ You can install [the fd-find package](https://www.freshports.org/sysutils/fd) fr pkg install fd-find ``` -### From NPM +### From npm On linux and macOS, you can install the [fd-find](https://npm.im/fd-find) package: @@ -639,7 +647,7 @@ With Rust's package manager [cargo](https://github.com/rust-lang/cargo), you can ``` cargo install fd-find ``` -Note that rust version *1.42.0* or later is required. +Note that rust version *1.53.0* or later is required. `make` is also needed for the build. @@ -667,7 +675,6 @@ cargo install --path . - [sharkdp](https://github.com/sharkdp) - [tmccombs](https://github.com/tmccombs) - [tavianator](https://github.com/tavianator) -- [pemistahl](https://github.com/pemistahl/) ## License diff --git a/build.rs b/build.rs index ddf9e15..d2d9566 100644 --- a/build.rs +++ b/build.rs @@ -5,7 +5,7 @@ use clap::Shell; include!("src/app.rs"); fn main() { - let min_version = "1.42"; + let min_version = "1.53"; match version_check::is_min_version(min_version) { Some(true) => {} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..e51b4f3 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.53.0" diff --git a/contrib/completion/_fd b/contrib/completion/_fd index 3f43b0f..f8521a7 100644 --- a/contrib/completion/_fd +++ b/contrib/completion/_fd @@ -138,6 +138,7 @@ _fd() { + '(exec-cmds)' # execute command '(long-listing max-results)'{-x+,--exec=}'[execute command for each search result]:command: _command_names -e:*\;::program arguments: _normal' '(long-listing max-results)'{-X+,--exec-batch=}'[execute command for all search results at once]:command: _command_names -e:*\;::program arguments: _normal' + '(long-listing max-results)--batch-size=[max number of args for each -X call]:size' + other '!(--max-buffer-time)--max-buffer-time=[set amount of time to buffer before showing output]:time (ms)' @@ -220,7 +221,7 @@ _fd() { _fd "$@" # ------------------------------------------------------------------------------ -# Copyright (c) 2011 Github zsh-users - http://github.com/zsh-users +# Copyright (c) 2011 GitHub zsh-users - http://github.com/zsh-users # All rights reserved. # # Redistribution and use in source and binary forms, with or without diff --git a/doc/fd.1 b/doc/fd.1 index 43413ab..51d0c03 100644 --- a/doc/fd.1 +++ b/doc/fd.1 @@ -409,5 +409,11 @@ $ fd -e py .TP .RI "Open all search results with vim:" $ fd pattern -X vim +.TP +.BI "\-\-batch\-size " size +Pass at most +.I size +arguments to each call to the command given with -X. +.TP .SH SEE ALSO .BR find (1) diff --git a/src/app.rs b/src/app.rs index d697ae1..4c17dca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -64,6 +64,7 @@ pub fn build_app() -> App<'static, 'static> { .long("no-ignore-vcs") .overrides_with("no-ignore-vcs") .hidden_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.", @@ -78,10 +79,22 @@ pub fn build_app() -> App<'static, 'static> { "Overrides --no-ignore-vcs.", ), ) + .arg( + Arg::with_name("no-ignore-parent") + .long("no-ignore-parent") + .overrides_with("no-ignore-parent") + .hidden_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::with_name("no-global-ignore-file") .long("no-global-ignore-file") .hidden(true) + .help("Do not respect the global ignore file") .long_help("Do not respect the global ignore file."), ) .arg( @@ -91,6 +104,7 @@ pub fn build_app() -> App<'static, 'static> { .overrides_with_all(&["ignore", "no-hidden"]) .multiple(true) .hidden_short_help(true) + .help("Alias for '--no-ignore', and '--hidden' when given twice") .long_help( "Alias for '--no-ignore'. Can be repeated. '-uu' is an alias for \ '--no-ignore --hidden'.", @@ -134,6 +148,7 @@ pub fn build_app() -> App<'static, 'static> { .long("regex") .overrides_with_all(&["glob", "regex"]) .hidden_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.", @@ -146,6 +161,7 @@ pub fn build_app() -> App<'static, 'static> { .alias("literal") .overrides_with("fixed-strings") .hidden_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 \ @@ -250,6 +266,7 @@ pub fn build_app() -> App<'static, 'static> { .long("maxdepth") .hidden(true) .takes_value(true) + .help("Set maximum search depth (default: none)") ) .arg( Arg::with_name("min-depth") @@ -257,6 +274,7 @@ pub fn build_app() -> App<'static, 'static> { .takes_value(true) .value_name("depth") .hidden_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'", @@ -269,6 +287,7 @@ pub fn build_app() -> App<'static, 'static> { .value_name("depth") .hidden_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 '.", @@ -279,7 +298,9 @@ pub fn build_app() -> App<'static, 'static> { .long("prune") .conflicts_with_all(&["size", "exact-depth"]) .hidden_short_help(true) - .long_help("Do not traverse into matching directories.") + .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::with_name("file-type") @@ -395,6 +416,21 @@ pub fn build_app() -> App<'static, 'static> { " ), ) + .arg( + Arg::with_name("batch-size") + .long("batch-size") + .takes_value(true) + .value_name("size") + .hidden_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.", + ), + ) .arg( Arg::with_name("exclude") .long("exclude") @@ -421,6 +457,7 @@ pub fn build_app() -> App<'static, 'static> { .number_of_values(1) .multiple(true) .hidden_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.", @@ -449,6 +486,7 @@ pub fn build_app() -> App<'static, 'static> { .takes_value(true) .value_name("num") .hidden_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)", @@ -462,7 +500,7 @@ pub fn build_app() -> App<'static, 'static> { .number_of_values(1) .allow_hyphen_values(true) .multiple(true) - .help("Limit results based on the size of files.") + .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 \ @@ -487,6 +525,7 @@ pub fn build_app() -> App<'static, 'static> { .long("max-buffer-time") .takes_value(true) .hidden(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.", @@ -543,6 +582,7 @@ pub fn build_app() -> App<'static, 'static> { // the files they saw in the previous search. .conflicts_with_all(&["exec", "exec-batch", "list-details"]) .hidden_short_help(true) + .help("Limit number of search results") .long_help("Limit the number of search results to 'count' and quit immediately."), ) .arg( @@ -551,6 +591,7 @@ pub fn build_app() -> App<'static, 'static> { .hidden_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'.") ) @@ -561,6 +602,7 @@ pub fn build_app() -> App<'static, 'static> { .alias("has-results") .hidden_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 \ @@ -573,6 +615,7 @@ pub fn build_app() -> App<'static, 'static> { .long("show-errors") .hidden_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.", @@ -585,6 +628,7 @@ pub fn build_app() -> App<'static, 'static> { .value_name("path") .number_of_values(1) .hidden_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 \ @@ -608,6 +652,7 @@ pub fn build_app() -> App<'static, 'static> { .value_name("separator") .long("path-separator") .hidden_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).", @@ -630,6 +675,7 @@ pub fn build_app() -> App<'static, 'static> { .multiple(true) .hidden_short_help(true) .number_of_values(1) + .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 [FLAGS/OPTIONS] --search-path \ @@ -666,6 +712,7 @@ pub fn build_app() -> App<'static, 'static> { .long("one-file-system") .aliases(&["mount", "xdev"]) .hidden_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 \ diff --git a/src/options.rs b/src/config.rs similarity index 93% rename from src/options.rs rename to src/config.rs index c17dd4d..c11f88b 100644 --- a/src/options.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ use crate::filter::OwnerFilter; use crate::filter::{SizeFilter, TimeFilter}; /// Configuration options for *fd*. -pub struct Options { +pub struct Config { /// Whether the search is case-sensitive or case-insensitive. pub case_sensitive: bool, @@ -24,6 +24,9 @@ pub struct Options { /// Whether to respect `.fdignore` files or not. pub read_fdignore: bool, + /// Whether to respect ignore files in parent directories or not. + pub read_parent_ignore: bool, + /// Whether to respect VCS ignore files (`.gitignore`, ..) or not. pub read_vcsignore: bool, @@ -82,6 +85,10 @@ pub struct Options { /// If a value is supplied, each item found will be used to generate and execute commands. pub command: Option>, + /// Maximum number of search results to pass to each `command`. If zero, the number is + /// unlimited. + pub batch_size: usize, + /// A list of glob patterns that should be excluded from the search. pub exclude_patterns: Vec, diff --git a/src/exec/job.rs b/src/exec/job.rs index c43be5b..aa8164c 100644 --- a/src/exec/job.rs +++ b/src/exec/job.rs @@ -42,7 +42,7 @@ pub fn job( results.push(cmd.generate_and_execute(&value, Arc::clone(&out_perm), buffer_output)) } // Returns error in case of any error. - merge_exitcodes(&results) + merge_exitcodes(results) } pub fn batch( @@ -50,6 +50,7 @@ pub fn batch( cmd: &CommandTemplate, show_filesystem_errors: bool, buffer_output: bool, + limit: usize, ) -> ExitCode { let paths = rx.iter().filter_map(|value| match value { WorkerResult::Entry(val) => Some(val), @@ -60,5 +61,17 @@ pub fn batch( None } }); - cmd.generate_and_execute_batch(paths, buffer_output) + if limit == 0 { + // no limit + return cmd.generate_and_execute_batch(paths, buffer_output); + } + + let mut exit_codes = Vec::new(); + let mut peekable = paths.peekable(); + while peekable.peek().is_some() { + let limited = peekable.by_ref().take(limit); + let exit_code = cmd.generate_and_execute_batch(limited, buffer_output); + exit_codes.push(exit_code); + } + merge_exitcodes(exit_codes) } diff --git a/src/exit_codes.rs b/src/exit_codes.rs index 2083b32..4f8a974 100644 --- a/src/exit_codes.rs +++ b/src/exit_codes.rs @@ -23,8 +23,8 @@ impl ExitCode { } } -pub fn merge_exitcodes(results: &[ExitCode]) -> ExitCode { - if results.iter().any(|&c| ExitCode::is_error(c)) { +pub fn merge_exitcodes(results: impl IntoIterator) -> ExitCode { + if results.into_iter().any(ExitCode::is_error) { return ExitCode::GeneralError; } ExitCode::Success @@ -36,38 +36,38 @@ mod tests { #[test] fn success_when_no_results() { - assert_eq!(merge_exitcodes(&[]), ExitCode::Success); + assert_eq!(merge_exitcodes([]), ExitCode::Success); } #[test] fn general_error_if_at_least_one_error() { assert_eq!( - merge_exitcodes(&[ExitCode::GeneralError]), + merge_exitcodes([ExitCode::GeneralError]), ExitCode::GeneralError ); assert_eq!( - merge_exitcodes(&[ExitCode::KilledBySigint]), + merge_exitcodes([ExitCode::KilledBySigint]), ExitCode::GeneralError ); assert_eq!( - merge_exitcodes(&[ExitCode::KilledBySigint, ExitCode::Success]), + merge_exitcodes([ExitCode::KilledBySigint, ExitCode::Success]), ExitCode::GeneralError ); assert_eq!( - merge_exitcodes(&[ExitCode::Success, ExitCode::GeneralError]), + merge_exitcodes([ExitCode::Success, ExitCode::GeneralError]), ExitCode::GeneralError ); assert_eq!( - merge_exitcodes(&[ExitCode::GeneralError, ExitCode::KilledBySigint]), + merge_exitcodes([ExitCode::GeneralError, ExitCode::KilledBySigint]), ExitCode::GeneralError ); } #[test] fn success_if_no_error() { - assert_eq!(merge_exitcodes(&[ExitCode::Success]), ExitCode::Success); + assert_eq!(merge_exitcodes([ExitCode::Success]), ExitCode::Success); assert_eq!( - merge_exitcodes(&[ExitCode::Success, ExitCode::Success]), + merge_exitcodes([ExitCode::Success, ExitCode::Success]), ExitCode::Success ); } diff --git a/src/filetypes.rs b/src/filetypes.rs index 2baf0f2..10872a0 100644 --- a/src/filetypes.rs +++ b/src/filetypes.rs @@ -37,9 +37,9 @@ impl FileTypes { || (self.executables_only && !entry .metadata() - .map(|m| filesystem::is_executable(&m)) + .map(|m| filesystem::is_executable(m)) .unwrap_or(false)) - || (self.empty_only && !filesystem::is_empty(&entry)) + || (self.empty_only && !filesystem::is_empty(entry)) || !(entry_type.is_file() || entry_type.is_dir() || entry_type.is_symlink() diff --git a/src/main.rs b/src/main.rs index f0ce9a6..321df54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ mod app; +mod config; mod error; mod exec; mod exit_codes; mod filesystem; mod filetypes; mod filter; -mod options; mod output; mod regex_helper; mod walk; @@ -23,6 +23,7 @@ use lscolors::LsColors; use normpath::PathExt; use regex::bytes::{RegexBuilder, RegexSetBuilder}; +use crate::config::Config; use crate::error::print_error; use crate::exec::CommandTemplate; use crate::exit_codes::ExitCode; @@ -30,7 +31,6 @@ use crate::filetypes::FileTypes; #[cfg(unix)] use crate::filter::OwnerFilter; use crate::filter::{SizeFilter, TimeFilter}; -use crate::options::Options; 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 @@ -50,10 +50,38 @@ ow=0:or=0;38;5;16;48;5;203:no=0:ex=1;38;5;203:cd=0;38;5;203;48;5;236:mi=0;38;5;1 38;5;185:*.jpg=0;38;5;208:*.mir=0;38;5;48:*.sxi=0;38;5;186:*.bz2=4;38;5;203:*.odt=0;38;5;186:*.mov=0;38;5;208:*.toc=0;38;5;243:*.bat=1;38;5;203:*.asa=0;38;5;48:*.awk=0;38;5;48:*.sbt=0;38;5;48:*.vcd=4;38;5;203:*.kts=0;38;5;48:*.arj=4;38;5;203:*.blg=0;38;5;243:*.c++=0;38;5;48:*.odp=0;38;5;186:*.bbl=0;38;5;243:*.idx=0;38;5;243:*.com=1;38;5;203:*.mp3=0;38;5;208:*.avi=0;38;5;208:*.def=0;38;5;48:*.cgi=0;38;5;48:*.zip=4;38;5;203:*.ttf=0;38;5;208:*.ppt=0;38;5;186:*.tml=0;38;5;149:*.fsx=0;38;5;48:*.h++=0;38;5;48:*.rtf=0;38;5;186:*.inl=0;38;5;48:*.yaml=0;38;5;149:*.html=0;38;5;185:*.mpeg=0;38;5;208:*.java=0;38;5;48:*.hgrc=0;38;5;149:*.orig=0;38;5;243:*.conf=0;38;5;149:*.dart=0;38;5;48:*.psm1=0;38;5;48:*.rlib=0;38;5;243:*.fish=0;38;5;48:*.bash=0;38;5;48:*.make=0;38;5;149:*.docx=0;38;5;186:*.json=0;38;5;149:*.psd1=0;38;5;48:*.lisp=0;38;5;48:*.tbz2=4;38;5;203:*.diff=0;38;5;48:*.epub=0;38;5;186:*.xlsx=0;38;5;186:*.pptx=0;38;5;186:*.toml=0;38;5;149:*.h264=0;38;5;208:*.purs=0;38;5;48:*.flac=0;38;5;208:*.tiff=0;38;5;208:*.jpeg=0;38;5;208:*.lock=0;38;5;243:*.less=0;38;5;48:*.dyn_o=0;38;5;243:*.scala=0;38;5;48:*.mdown=0;38;5;185:*.shtml=0;38;5;185:*.class=0;38;5;243:*.cache=0;38;5;243:*.cmake=0;38;5;149:*passwd=0;38;5;149:*.swift=0;38;5;48:*shadow=0;38;5;149:*.xhtml=0;38;5;185:*.patch=0;38;5;48:*.cabal=0;38;5;48:*README=0;38;5;16;48;5;186:*.toast=4;38;5;203:*.ipynb=0;38;5;48:*COPYING=0;38;5;249:*.gradle=0;38;5;48:*.matlab=0;38;5;48:*.config=0;38;5;149:*LICENSE=0;38;5;249:*.dyn_hi=0;38;5;243:*.flake8=0;38;5;149:*.groovy=0;38;5;48:*INSTALL=0;38;5;16;48;5;186:*TODO.md=1:*.ignore=0;38;5;149:*Doxyfile=0;38;5;149:*TODO.txt=1:*setup.py=0;38;5;149:*Makefile=0;38;5;149:*.gemspec=0;38;5;149:*.desktop=0;38;5;149:*.rgignore=0;38;5;149:*.markdown=0;38;5;185:*COPYRIGHT=0;38;5;249:*configure=0;38;5;149:*.DS_Store=0;38;5;243:*.kdevelop=0;38;5;149:*.fdignore=0;38;5;149:*README.md=0;38;5;16;48;5;186:*.cmake.in=0;38;5;149:*SConscript=0;38;5;149:*CODEOWNERS=0;38;5;149:*.localized=0;38;5;243:*.gitignore=0;38;5;149:*Dockerfile=0;38;5;149:*.gitconfig=0;38;5;149:*INSTALL.md=0;38;5;16;48;5;186:*README.txt=0;38;5;16;48;5;186:*SConstruct=0;38;5;149:*.scons_opt=0;38;5;243:*.travis.yml=0;38;5;186:*.gitmodules=0;38;5;149:*.synctex.gz=0;38;5;243:*LICENSE-MIT=0;38;5;249:*MANIFEST.in=0;38;5;149:*Makefile.in=0;38;5;243:*Makefile.am=0;38;5;149:*INSTALL.txt=0;38;5;16;48;5;186:*configure.ac=0;38;5;149:*.applescript=0;38;5;48:*appveyor.yml=0;38;5;186:*.fdb_latexmk=0;38;5;243:*CONTRIBUTORS=0;38;5;16;48;5;186:*.clang-format=0;38;5;149:*LICENSE-APACHE=0;38;5;249:*CMakeLists.txt=0;38;5;149:*CMakeCache.txt=0;38;5;243:*.gitattributes=0;38;5;149:*CONTRIBUTORS.md=0;38;5;16;48;5;186:*.sconsign.dblite=0;38;5;243:*requirements.txt=0;38;5;149:*CONTRIBUTORS.txt=0;38;5;16;48;5;186:*package-lock.json=0;38;5;243:*.CFUserTextEncoding=0;38;5;243 "; +fn main() { + let result = run(); + match result { + Ok(exit_code) => { + process::exit(exit_code.into()); + } + Err(err) => { + eprintln!("[fd error]: {:#}", err); + process::exit(ExitCode::GeneralError.into()); + } + } +} + fn run() -> Result { let matches = app::build_app().get_matches_from(env::args_os()); - // Set the current working directory of the process + set_working_dir(&matches)?; + let current_directory = Path::new("."); + ensure_current_directory_exists(current_directory)?; + let search_paths = extract_search_paths(&matches, current_directory)?; + + let pattern = extract_search_pattern(&matches)?; + ensure_search_pattern_is_not_a_path(&matches, pattern)?; + let pattern_regex = build_pattern_regex(&matches, pattern)?; + + let config = construct_config(matches, &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); if !filesystem::is_existing_directory(base_directory) { @@ -69,15 +97,20 @@ fn run() -> Result { ) })?; } + Ok(()) +} - let current_directory = Path::new("."); - if !filesystem::is_existing_directory(current_directory) { - return Err(anyhow!( +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?)." - )); + )) } +} - // Get the search pattern +fn extract_search_pattern<'a>(matches: &'a clap::ArgMatches) -> Result<&'a str> { let pattern = matches .value_of_os("pattern") .map(|p| { @@ -86,54 +119,57 @@ fn run() -> Result { }) .transpose()? .unwrap_or(""); + Ok(pattern) +} - // Get one or more root directories to search. - let passed_arguments = matches +fn extract_search_paths( + matches: &clap::ArgMatches, + current_directory: &Path, +) -> Result> { + let mut search_paths = matches .values_of_os("path") - .or_else(|| matches.values_of_os("search-path")); - - let mut search_paths = if let Some(paths) = passed_arguments { - let mut directories = vec![]; - for path in paths { - let path_buffer = PathBuf::from(path); - if filesystem::is_existing_directory(&path_buffer) { - directories.push(path_buffer); - } else { - print_error(format!( - "Search path '{}' is not a directory.", - path_buffer.to_string_lossy() - )); - } - } - - directories - } else { - vec![current_directory.to_path_buf()] - }; - - // Check if we have no valid search paths. + .or_else(|| matches.values_of_os("search-path")) + .map_or_else( + || vec![current_directory.to_path_buf()], + |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() + }, + ); if search_paths.is_empty() { return Err(anyhow!("No valid search paths given.")); } - if matches.is_present("absolute-path") { - search_paths = search_paths - .iter() - .map(|path_buffer| { - path_buffer - .normalize() - .and_then(|pb| filesystem::absolute_path(pb.as_path())) - .unwrap() - }) - .collect(); + update_to_absolute_paths(&mut search_paths); } + Ok(search_paths) +} - // Detect if the user accidentally supplied a path instead of a search pattern +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() { - return Err(anyhow!( + Err(anyhow!( "The search pattern '{pattern}' contains a path-separation character ('{sep}') \ and will not lead to any search results.\n\n\ If you want to search for all files inside the '{pattern}' directory, use a match-all pattern:\n\n \ @@ -142,10 +178,14 @@ fn run() -> Result { fd --full-path '{pattern}'", pattern = pattern, sep = std::path::MAIN_SEPARATOR, - )); + )) + } else { + Ok(()) } +} - let pattern_regex = if matches.is_present("glob") && !pattern.is_empty() { +fn build_pattern_regex(matches: &clap::ArgMatches, pattern: &str) -> Result { + Ok(if matches.is_present("glob") && !pattern.is_empty() { let glob = GlobBuilder::new(pattern).literal_separator(true).build()?; glob.regex().to_owned() } else if matches.is_present("fixed-strings") { @@ -153,17 +193,46 @@ fn run() -> Result { regex::escape(pattern) } else { String::from(pattern) - }; + }) +} +fn check_path_separator_length(path_separator: Option<&str>) -> Result<()> { + match (cfg!(windows), path_separator) { + (true, Some(sep)) if sep.len() > 1 => Err(anyhow!( + "A path separator must be exactly one byte, but \ + the given separator is {} bytes: '{}'.\n\ + In some shells on Windows, '/' is automatically \ + expanded. Try to use '//' instead.", + sep.len(), + sep + )), + _ => Ok(()), + } +} + +fn construct_config(matches: clap::ArgMatches, 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)); + && (matches.is_present("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())); + check_path_separator_length(path_separator.as_deref())?; + + let size_limits = extract_size_limits(&matches)?; + let time_constraints = extract_time_constraints(&matches)?; + #[cfg(unix)] + let owner_constraint = matches + .value_of("owner") + .map(OwnerFilter::from_string) + .transpose()? + .flatten(); #[cfg(windows)] let ansi_colors_support = ansi_term::enable_ansi_support().is_ok() || std::env::var_os("TERM").is_some(); - #[cfg(not(windows))] let ansi_colors_support = true; @@ -174,163 +243,14 @@ fn run() -> Result { _ => ansi_colors_support && env::var_os("NO_COLOR").is_none() && interactive_terminal, }; - let path_separator = matches - .value_of("path-separator") - .map_or_else(filesystem::default_path_separator, |s| Some(s.to_owned())); - - #[cfg(windows)] - { - if let Some(ref sep) = path_separator { - if sep.len() > 1 { - return Err(anyhow!( - "A path separator must be exactly one byte, but \ - the given separator is {} bytes: '{}'.\n\ - In some shells on Windows, '/' is automatically \ - expanded. Try to use '//' instead.", - sep.len(), - sep - )); - }; - }; - } - let ls_colors = if colored_output { Some(LsColors::from_env().unwrap_or_else(|| LsColors::from_string(DEFAULT_LS_COLORS))) } else { None }; + let command = extract_command(&matches, path_separator.as_deref(), colored_output)?; - let command = if let Some(args) = matches.values_of("exec") { - Some(CommandTemplate::new(args, path_separator.clone())) - } else if let Some(args) = matches.values_of("exec-batch") { - Some(CommandTemplate::new_batch(args, path_separator.clone())?) - } else if matches.is_present("list-details") { - let color = matches.value_of("color").unwrap_or("auto"); - let color_arg = ["--color=", color].concat(); - - #[allow(unused)] - let gnu_ls = |command_name| { - // Note: we use short options here (instead of --long-options) to support more - // platforms (like BusyBox). - vec![ - command_name, - "-l", // long listing format - "-h", // human readable file sizes - "-d", // list directories themselves, not their contents - &color_arg, - ] - }; - - let cmd: Vec<&str> = if cfg!(unix) { - if !cfg!(any( - target_os = "macos", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - )) { - // Assume ls is GNU ls - gnu_ls("ls") - } else { - // MacOS, DragonFlyBSD, FreeBSD - use std::process::{Command, Stdio}; - - // Use GNU ls, if available (support for --color=auto, better LS_COLORS support) - let gnu_ls_exists = Command::new("gls") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok(); - - if gnu_ls_exists { - gnu_ls("gls") - } else { - let mut cmd = vec![ - "ls", // BSD version of ls - "-l", // long listing format - "-h", // '--human-readable' is not available, '-h' is - "-d", // '--directory' is not available, but '-d' is - ]; - - if !cfg!(any(target_os = "netbsd", target_os = "openbsd")) && colored_output { - // -G is not available in NetBSD's and OpenBSD's ls - cmd.push("-G"); - } - - cmd - } - } - } else if cfg!(windows) { - use std::process::{Command, Stdio}; - - // Use GNU ls, if available - let gnu_ls_exists = Command::new("ls") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok(); - - if gnu_ls_exists { - gnu_ls("ls") - } else { - return Err(anyhow!( - "'fd --list-details' is not supported on Windows unless GNU 'ls' is installed." - )); - } - } else { - return Err(anyhow!( - "'fd --list-details' is not supported on this platform." - )); - }; - - Some(CommandTemplate::new_batch(&cmd, path_separator.clone()).unwrap()) - } else { - None - }; - - let size_limits = if let Some(vs) = matches.values_of("size") { - vs.map(|sf| { - SizeFilter::from_string(sf) - .ok_or_else(|| anyhow!("'{}' is not a valid size constraint. See 'fd --help'.", sf)) - }) - .collect::>>()? - } else { - vec![] - }; - - let now = time::SystemTime::now(); - let mut time_constraints: Vec = Vec::new(); - if let Some(t) = matches.value_of("changed-within") { - if let Some(f) = TimeFilter::after(&now, t) { - time_constraints.push(f); - } else { - return Err(anyhow!( - "'{}' is not a valid date or duration. See 'fd --help'.", - t - )); - } - } - if let Some(t) = matches.value_of("changed-before") { - if let Some(f) = TimeFilter::before(&now, t) { - time_constraints.push(f); - } else { - return Err(anyhow!( - "'{}' is not a valid date or duration. See 'fd --help'.", - t - )); - } - } - - #[cfg(unix)] - let owner_constraint = if let Some(s) = matches.value_of("owner") { - OwnerFilter::from_string(s)? - } else { - None - }; - - let config = Options { + Ok(Config { case_sensitive, search_full_path: matches.is_present("full-path"), ignore_hidden: !(matches.is_present("hidden") @@ -340,6 +260,10 @@ fn run() -> Result { read_vcsignore: !(matches.is_present("no-ignore") || matches.is_present("rg-alias-hidden-ignore") || matches.is_present("no-ignore-vcs")), + read_parent_ignore: !(matches.is_present("no-ignore") + || matches.is_present("rg-alias-hidden-ignore") + || matches.is_present("no-ignore-vcs") + || matches.is_present("no-ignore-parent")), read_global_ignore: !(matches.is_present("no-ignore") || matches.is_present("rg-alias-hidden-ignore") || matches.is_present("no-global-ignore-file")), @@ -424,6 +348,12 @@ fn run() -> Result { }) .transpose()?, command: command.map(Arc::new), + batch_size: matches + .value_of("batch-size") + .map(|n| n.parse::()) + .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()) @@ -451,20 +381,177 @@ fn run() -> Result { None } }), - }; + }) +} - if cfg!(unix) - && config.ignore_hidden - && pattern_matches_strings_with_leading_dot(&pattern_regex) - { +fn extract_command( + matches: &clap::ArgMatches, + path_separator: Option<&str>, + colored_output: bool, +) -> Result> { + None.or_else(|| { + matches.values_of("exec").map(|args| { + Ok(CommandTemplate::new( + args, + path_separator.map(str::to_string), + )) + }) + }) + .or_else(|| { + matches + .values_of("exec-batch") + .map(|args| CommandTemplate::new_batch(args, path_separator.map(str::to_string))) + }) + .or_else(|| { + if !matches.is_present("list-details") { + return None; + } + + 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| { + CommandTemplate::new_batch(cmd, path_separator.map(str::to_string)).unwrap() + }); + + Some(res) + }) + .transpose() +} + +fn determine_ls_command(color_arg: &str, colored_output: bool) -> Result> { + #[allow(unused)] + let gnu_ls = |command_name| { + // Note: we use short options here (instead of --long-options) to support more + // platforms (like BusyBox). + vec![ + command_name, + "-l", // long listing format + "-h", // human readable file sizes + "-d", // list directories themselves, not their contents + color_arg, + ] + }; + let cmd: Vec<&str> = if cfg!(unix) { + if !cfg!(any( + target_os = "macos", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )) { + // Assume ls is GNU ls + gnu_ls("ls") + } else { + // MacOS, DragonFlyBSD, FreeBSD + use std::process::{Command, Stdio}; + + // Use GNU ls, if available (support for --color=auto, better LS_COLORS support) + let gnu_ls_exists = Command::new("gls") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok(); + + if gnu_ls_exists { + gnu_ls("gls") + } else { + let mut cmd = vec![ + "ls", // BSD version of ls + "-l", // long listing format + "-h", // '--human-readable' is not available, '-h' is + "-d", // '--directory' is not available, but '-d' is + ]; + + if !cfg!(any(target_os = "netbsd", target_os = "openbsd")) && colored_output { + // -G is not available in NetBSD's and OpenBSD's ls + cmd.push("-G"); + } + + cmd + } + } + } else if cfg!(windows) { + use std::process::{Command, Stdio}; + + // Use GNU ls, if available + let gnu_ls_exists = Command::new("ls") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok(); + + if gnu_ls_exists { + gnu_ls("ls") + } else { + return Err(anyhow!( + "'fd --list-details' is not supported on Windows unless GNU 'ls' is installed." + )); + } + } else { return Err(anyhow!( + "'fd --list-details' is not supported on this platform." + )); + }; + Ok(cmd) +} + +fn extract_size_limits(matches: &clap::ArgMatches) -> 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> { + let now = time::SystemTime::now(); + let mut time_constraints: Vec = Vec::new(); + if let Some(t) = matches.value_of("changed-within") { + if let Some(f) = TimeFilter::after(&now, t) { + time_constraints.push(f); + } else { + return Err(anyhow!( + "'{}' is not a valid date or duration. See 'fd --help'.", + t + )); + } + } + if let Some(t) = matches.value_of("changed-before") { + if let Some(f) = TimeFilter::before(&now, t) { + time_constraints.push(f); + } else { + return Err(anyhow!( + "'{}' is not a valid date or duration. See 'fd --help'.", + t + )); + } + } + Ok(time_constraints) +} + +fn ensure_use_hidden_option_for_leading_dot_pattern( + config: &Config, + pattern_regex: &str, +) -> Result<()> { + if cfg!(unix) && config.ignore_hidden && pattern_matches_strings_with_leading_dot(pattern_regex) + { + Err(anyhow!( "The pattern seems to only match files with a leading dot, but hidden files are \ filtered by default. Consider adding -H/--hidden to search hidden files as well \ or adjust your search pattern." - )); + )) + } else { + Ok(()) } +} - let re = RegexBuilder::new(&pattern_regex) +fn build_regex(pattern_regex: String, config: &Config) -> Result { + RegexBuilder::new(&pattern_regex) .case_insensitive(!config.case_sensitive) .dot_matches_new_line(true) .build() @@ -475,20 +562,5 @@ fn run() -> Result { also use the '--glob' option to match on a glob pattern.", e.to_string() ) - })?; - - walk::scan(&search_paths, Arc::new(re), Arc::new(config)) -} - -fn main() { - let result = run(); - match result { - Ok(exit_code) => { - process::exit(exit_code.into()); - } - Err(err) => { - eprintln!("[fd error]: {:#}", err); - process::exit(ExitCode::GeneralError.into()); - } - } + }) } diff --git a/src/output.rs b/src/output.rs index 471b8fb..536626d 100644 --- a/src/output.rs +++ b/src/output.rs @@ -6,10 +6,10 @@ use std::sync::Arc; use lscolors::{LsColors, Style}; +use crate::config::Config; use crate::error::print_error; use crate::exit_codes::ExitCode; use crate::filesystem::strip_current_dir; -use crate::options::Options; fn replace_path_separator(path: &str, new_path_separator: &str) -> String { path.replace(std::path::MAIN_SEPARATOR, new_path_separator) @@ -19,7 +19,7 @@ fn replace_path_separator(path: &str, new_path_separator: &str) -> String { pub fn print_entry( stdout: &mut StdoutLock, entry: &Path, - config: &Options, + config: &Config, wants_to_quit: &Arc, ) { let path = if entry.is_absolute() { @@ -49,7 +49,7 @@ pub fn print_entry( fn print_entry_colorized( stdout: &mut StdoutLock, path: &Path, - config: &Options, + config: &Config, ls_colors: &LsColors, wants_to_quit: &Arc, ) -> io::Result<()> { @@ -85,7 +85,7 @@ fn print_entry_colorized( fn print_entry_uncolorized_base( stdout: &mut StdoutLock, path: &Path, - config: &Options, + config: &Config, ) -> io::Result<()> { let separator = if config.null_separator { "\0" } else { "\n" }; @@ -100,7 +100,7 @@ fn print_entry_uncolorized_base( fn print_entry_uncolorized( stdout: &mut StdoutLock, path: &Path, - config: &Options, + config: &Config, ) -> io::Result<()> { print_entry_uncolorized_base(stdout, path, config) } @@ -109,7 +109,7 @@ fn print_entry_uncolorized( fn print_entry_uncolorized( stdout: &mut StdoutLock, path: &Path, - config: &Options, + config: &Config, ) -> io::Result<()> { use std::os::unix::ffi::OsStrExt; diff --git a/src/walk.rs b/src/walk.rs index a053278..789a500 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -13,13 +13,14 @@ use std::time; use anyhow::{anyhow, Result}; use ignore::overrides::OverrideBuilder; use ignore::{self, WalkBuilder}; +use once_cell::unsync::OnceCell; use regex::bytes::Regex; +use crate::config::Config; use crate::error::print_error; use crate::exec; use crate::exit_codes::{merge_exitcodes, ExitCode}; use crate::filesystem; -use crate::options::Options; use crate::output; /// The receiver thread can either be buffering results or directly streaming to the console. @@ -40,13 +41,15 @@ pub enum WorkerResult { /// Maximum size of the output buffer before flushing results to the console pub const MAX_BUFFER_LENGTH: usize = 1000; +/// Default duration until output buffering switches to streaming. +pub const DEFAULT_MAX_BUFFER_TIME: time::Duration = time::Duration::from_millis(100); /// Recursively scan the given search path for files / pathnames matching the pattern. /// /// If the `--exec` argument was supplied, this will create a thread pool for executing /// jobs in parallel from a given command line and the discovered paths. Otherwise, each /// path will simply be written to standard output. -pub fn scan(path_vec: &[PathBuf], pattern: Arc, config: Arc) -> Result { +pub fn scan(path_vec: &[PathBuf], pattern: Arc, config: Arc) -> Result { let mut path_iter = path_vec.iter(); let first_path_buf = path_iter .next() @@ -68,7 +71,7 @@ pub fn scan(path_vec: &[PathBuf], pattern: Arc, config: Arc) -> walker .hidden(config.ignore_hidden) .ignore(config.read_fdignore) - .parents(config.read_fdignore || config.read_vcsignore) + .parents(config.read_parent_ignore) .git_ignore(config.read_vcsignore) .git_global(config.read_vcsignore) .git_exclude(config.read_vcsignore) @@ -161,7 +164,7 @@ pub fn scan(path_vec: &[PathBuf], pattern: Arc, config: Arc) -> } fn spawn_receiver( - config: &Arc, + config: &Arc, wants_to_quit: &Arc, rx: Receiver, ) -> thread::JoinHandle { @@ -176,7 +179,13 @@ fn spawn_receiver( // This will be set to `Some` if the `--exec` argument was supplied. if let Some(ref cmd) = config.command { if cmd.in_batch_mode() { - exec::batch(rx, cmd, show_filesystem_errors, enable_output_buffering) + exec::batch( + rx, + cmd, + show_filesystem_errors, + enable_output_buffering, + config.batch_size, + ) } else { let shared_rx = Arc::new(Mutex::new(rx)); @@ -205,12 +214,11 @@ fn spawn_receiver( } // Wait for all threads to exit before exiting the program. - let mut results: Vec = Vec::new(); - for h in handles { - results.push(h.join().unwrap()); - } - - merge_exitcodes(&results) + let exit_codes = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + merge_exitcodes(exit_codes) } } else { let start = time::Instant::now(); @@ -221,9 +229,7 @@ fn spawn_receiver( let mut mode = ReceiverMode::Buffering; // Maximum time to wait before we start streaming to the console. - let max_buffer_time = config - .max_buffer_time - .unwrap_or_else(|| time::Duration::from_millis(100)); + let max_buffer_time = config.max_buffer_time.unwrap_or(DEFAULT_MAX_BUFFER_TIME); let stdout = io::stdout(); let mut stdout = stdout.lock(); @@ -243,7 +249,7 @@ fn spawn_receiver( // Have we reached the maximum buffer size or maximum buffering time? if buffer.len() > MAX_BUFFER_LENGTH - || time::Instant::now() - start > max_buffer_time + || start.elapsed() > max_buffer_time { // Flush the buffer for v in &buffer { @@ -266,6 +272,11 @@ fn spawn_receiver( } num_results += 1; + if let Some(max_results) = config.max_results { + if num_results >= max_results { + break; + } + } } WorkerResult::Error(err) => { if show_filesystem_errors { @@ -273,21 +284,13 @@ fn spawn_receiver( } } } - - if let Some(max_results) = config.max_results { - if num_results >= max_results { - break; - } - } } // If we have finished fast enough (faster than max_buffer_time), we haven't streamed // anything to the console, yet. In this case, sort the results and print them: - if !buffer.is_empty() { - buffer.sort(); - for value in buffer { - output::print_entry(&mut stdout, &value, &config, &wants_to_quit); - } + buffer.sort(); + for value in buffer { + output::print_entry(&mut stdout, &value, &config, &wants_to_quit); } if config.quiet { @@ -299,45 +302,64 @@ fn spawn_receiver( }) } -pub enum DirEntry { +enum DirEntryInner { Normal(ignore::DirEntry), BrokenSymlink(PathBuf), } +pub struct DirEntry { + inner: DirEntryInner, + metadata: OnceCell>, +} + impl DirEntry { + fn normal(e: ignore::DirEntry) -> Self { + Self { + inner: DirEntryInner::Normal(e), + metadata: OnceCell::new(), + } + } + + fn broken_symlink(path: PathBuf) -> Self { + Self { + inner: DirEntryInner::BrokenSymlink(path), + metadata: OnceCell::new(), + } + } + pub fn path(&self) -> &Path { - match self { - DirEntry::Normal(e) => e.path(), - DirEntry::BrokenSymlink(pathbuf) => pathbuf.as_path(), + match &self.inner { + DirEntryInner::Normal(e) => e.path(), + DirEntryInner::BrokenSymlink(pathbuf) => pathbuf.as_path(), } } pub fn file_type(&self) -> Option { - match self { - DirEntry::Normal(e) => e.file_type(), - DirEntry::BrokenSymlink(pathbuf) => { - pathbuf.symlink_metadata().map(|m| m.file_type()).ok() - } + match &self.inner { + DirEntryInner::Normal(e) => e.file_type(), + DirEntryInner::BrokenSymlink(_) => self.metadata().map(|m| m.file_type()), } } - pub fn metadata(&self) -> Option { - match self { - DirEntry::Normal(e) => e.metadata().ok(), - DirEntry::BrokenSymlink(_) => None, - } + pub fn metadata(&self) -> Option<&Metadata> { + self.metadata + .get_or_init(|| match &self.inner { + DirEntryInner::Normal(e) => e.metadata().ok(), + DirEntryInner::BrokenSymlink(path) => path.symlink_metadata().ok(), + }) + .as_ref() } pub fn depth(&self) -> Option { - match self { - DirEntry::Normal(e) => Some(e.depth()), - DirEntry::BrokenSymlink(_) => None, + match &self.inner { + DirEntryInner::Normal(e) => Some(e.depth()), + DirEntryInner::BrokenSymlink(_) => None, } } } fn spawn_senders( - config: &Arc, + config: &Arc, wants_to_quit: &Arc, pattern: Arc, parallel_walker: ignore::WalkParallel, @@ -359,7 +381,7 @@ fn spawn_senders( // Skip the root directory entry. return ignore::WalkState::Continue; } - Ok(e) => DirEntry::Normal(e), + Ok(e) => DirEntry::normal(e), Err(ignore::Error::WithPath { path, err: inner_err, @@ -371,7 +393,7 @@ fn spawn_senders( .ok() .map_or(false, |m| m.file_type().is_symlink()) => { - DirEntry::BrokenSymlink(path) + DirEntry::broken_symlink(path) } _ => { return match tx_thread.send(WorkerResult::Error(ignore::Error::WithPath { @@ -440,7 +462,7 @@ fn spawn_senders( #[cfg(unix)] { if let Some(ref owner_constraint) = config.owner_constraint { - if let Ok(ref metadata) = entry_path.metadata() { + if let Some(metadata) = entry.metadata() { if !owner_constraint.matches(metadata) { return ignore::WalkState::Continue; } @@ -453,7 +475,7 @@ fn spawn_senders( // Filter out unwanted sizes if it is a file and we have been given size constraints. if !config.size_constraints.is_empty() { if entry_path.is_file() { - if let Ok(metadata) = entry_path.metadata() { + if let Some(metadata) = entry.metadata() { let file_size = metadata.len(); if config .size_constraints @@ -473,7 +495,7 @@ fn spawn_senders( // Filter out unwanted modification times if !config.time_constraints.is_empty() { let mut matched = false; - if let Ok(metadata) = entry_path.metadata() { + if let Some(metadata) = entry.metadata() { if let Ok(modified) = metadata.modified() { matched = config .time_constraints diff --git a/tests/tests.rs b/tests/tests.rs index 28383d9..54b4ecc 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -499,6 +499,78 @@ fn test_gitignore_and_fdignore() { ); } +/// Ignore parent ignore files (--no-ignore-parent) +#[test] +fn test_no_ignore_parent() { + let dirs = &["inner"]; + let files = &[ + "inner/parent-ignored", + "inner/child-ignored", + "inner/not-ignored", + ]; + let te = TestEnv::new(dirs, files); + + // Ignore 'parent-ignored' in root + fs::File::create(te.test_root().join(".gitignore")) + .unwrap() + .write_all(b"parent-ignored") + .unwrap(); + // Ignore 'child-ignored' in inner + fs::File::create(te.test_root().join("inner/.gitignore")) + .unwrap() + .write_all(b"child-ignored") + .unwrap(); + + te.assert_output_subdirectory("inner", &[], "not-ignored"); + + te.assert_output_subdirectory( + "inner", + &["--no-ignore-parent"], + "parent-ignored + not-ignored", + ); +} + +/// Ignore parent ignore files (--no-ignore-parent) with an inner git repo +#[test] +fn test_no_ignore_parent_inner_git() { + let dirs = &["inner"]; + let files = &[ + "inner/parent-ignored", + "inner/child-ignored", + "inner/not-ignored", + ]; + let te = TestEnv::new(dirs, files); + + // Make the inner folder also appear as a git repo + fs::create_dir_all(te.test_root().join("inner/.git")).unwrap(); + + // Ignore 'parent-ignored' in root + fs::File::create(te.test_root().join(".gitignore")) + .unwrap() + .write_all(b"parent-ignored") + .unwrap(); + // Ignore 'child-ignored' in inner + fs::File::create(te.test_root().join("inner/.gitignore")) + .unwrap() + .write_all(b"child-ignored") + .unwrap(); + + te.assert_output_subdirectory( + "inner", + &[], + "not-ignored + parent-ignored", + ); + + te.assert_output_subdirectory( + "inner", + &["--no-ignore-parent"], + "not-ignored + parent-ignored", + ); +} + /// Precedence of .fdignore files #[test] fn test_custom_ignore_precedence() { @@ -1347,6 +1419,48 @@ fn test_exec_batch() { } } +#[test] +fn test_exec_batch_with_limit() { + // TODO Test for windows + if cfg!(windows) { + return; + } + + let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES); + + te.assert_output( + &["foo", "--batch-size", "0", "--exec-batch", "echo", "{}"], + "a.foo one/b.foo one/two/C.Foo2 one/two/c.foo one/two/three/d.foo one/two/three/directory_foo", + ); + + let output = te.assert_success_and_get_output( + ".", + &["foo", "--batch-size=2", "--exec-batch", "echo", "{}"], + ); + let stdout = String::from_utf8_lossy(&output.stdout); + + for line in stdout.lines() { + assert_eq!(2, line.split_whitespace().count()); + } + + let mut paths: Vec<_> = stdout + .lines() + .flat_map(|line| line.split_whitespace()) + .collect(); + paths.sort_unstable(); + assert_eq!( + &paths, + &[ + "a.foo", + "one/b.foo", + "one/two/C.Foo2", + "one/two/c.foo", + "one/two/three/d.foo", + "one/two/three/directory_foo" + ], + ); +} + /// Shell script execution (--exec) with a custom --path-separator #[test] fn test_exec_with_separator() { @@ -1581,9 +1695,22 @@ fn create_file_with_modified>(path: P, duration_in_secs: u64) { filetime::set_file_times(&path, ft, ft).expect("time modification failed"); } +#[cfg(test)] +fn remove_symlink>(path: P) { + #[cfg(unix)] + fs::remove_file(path).expect("remove symlink"); + + // On Windows, symlinks remember whether they point to files or directories, so try both + #[cfg(windows)] + fs::remove_file(path.as_ref()) + .or_else(|_| fs::remove_dir(path.as_ref())) + .expect("remove symlink"); +} + #[test] fn test_modified_relative() { let te = TestEnv::new(&[], &[]); + remove_symlink(te.test_root().join("symlink")); create_file_with_modified(te.test_root().join("foo_0_now"), 0); create_file_with_modified(te.test_root().join("bar_1_min"), 60); create_file_with_modified(te.test_root().join("foo_10_min"), 600); @@ -1621,8 +1748,9 @@ fn change_file_modified>(path: P, iso_date: &str) { } #[test] -fn test_modified_asolute() { +fn test_modified_absolute() { let te = TestEnv::new(&[], &["15mar2018", "30dec2017"]); + remove_symlink(te.test_root().join("symlink")); change_file_modified(te.test_root().join("15mar2018"), "2018-03-15T12:00:00Z"); change_file_modified(te.test_root().join("30dec2017"), "2017-12-30T23:59:00Z");