From c4f19ccbbb7b9cde1fb46025db4f60ae7fb06a84 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:31:47 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .github/workflows/release.yml | 161 + .github/workflows/rust.yml | 56 + .gitignore | 22 + CNAME | 1 + Cargo.lock | 2960 ++++++++++++ Cargo.toml | 97 + Dockerfile | 8 + LICENSE-APACHE | 201 + LICENSE-MIT | 23 + README.md | 335 ++ README.wehub.md | 7 + docs/fselect.1 | 1189 +++++ docs/terminology.svg | 146 + docs/usage.md | 1099 +++++ fselect-completion.bash | 21 + resources/test/audio/silent-35s.mp3 | Bin 0 -> 139691 bytes resources/test/audio/silent.wav | Bin 0 -> 1440078 bytes resources/test/image/rect.svg | 3 + resources/test/image/rust-logo-blk.bmp | Bin 0 -> 62262 bytes resources/test/image/rust-logo-blk.gif | Bin 0 -> 2691 bytes resources/test/image/rust-logo-blk.jpeg | Bin 0 -> 4069 bytes resources/test/image/rust-logo-blk.jpg | Bin 0 -> 4069 bytes resources/test/image/rust-logo-blk.png | Bin 0 -> 7871 bytes resources/test/image/rust-logo-blk.svg | 1 + resources/test/image/rust-logo-blk.tiff | Bin 0 -> 124704 bytes resources/test/image/rust-logo-blk.webp | Bin 0 -> 2066 bytes .../test/image/rust-logo-blk_corrupted.svg | 1 + resources/test/video/rust-logo-blk.mkv | Bin 0 -> 3687 bytes resources/test/video/rust-logo-blk.mp4 | Bin 0 -> 3840 bytes src/config.rs | 185 + src/expr.rs | 1086 +++++ src/field/content_handlers.rs | 366 ++ src/field/context.rs | 282 ++ src/field/dispatch.rs | 188 + src/field/exif_handlers.rs | 95 + src/field/git_handlers.rs | 77 + src/field/hash_handlers.rs | 23 + src/field/media_handlers.rs | 93 + src/field/metadata_handlers.rs | 824 ++++ src/field/mod.rs | 1009 +++++ src/field/mode_handlers.rs | 135 + src/field/path_handlers.rs | 117 + src/fileinfo.rs | 20 + src/function.rs | 3953 +++++++++++++++++ src/ignore/docker.rs | 448 ++ src/ignore/hg.rs | 713 +++ src/ignore/mod.rs | 2 + src/lexer.rs | 3135 +++++++++++++ src/main.rs | 675 +++ src/mode.rs | 664 +++ src/operators.rs | 286 ++ src/output/csv.rs | 72 + src/output/flat.rs | 73 + src/output/html.rs | 62 + src/output/json.rs | 122 + src/output/mod.rs | 131 + src/parser.rs | 3467 +++++++++++++++ src/query.rs | 352 ++ src/searcher.rs | 3200 +++++++++++++ src/util/acl.rs | 323 ++ src/util/app_dirs.rs | 35 + src/util/audio.rs | 172 + src/util/capabilities.rs | 217 + src/util/datetime.rs | 405 ++ src/util/dimensions/image.rs | 92 + src/util/dimensions/mkv.rs | 58 + src/util/dimensions/mod.rs | 76 + src/util/dimensions/mp4.rs | 51 + src/util/dimensions/svg.rs | 187 + src/util/duration/mkv.rs | 55 + src/util/duration/mod.rs | 41 + src/util/duration/mp4.rs | 62 + src/util/error.rs | 149 + src/util/everything.rs | 117 + src/util/extattrs.rs | 129 + src/util/git.rs | 457 ++ src/util/glob.rs | 254 ++ src/util/greek.rs | 9 + src/util/japanese.rs | 19 + src/util/mod.rs | 2078 +++++++++ src/util/plocate.rs | 38 + src/util/top_n.rs | 134 + src/util/variant.rs | 529 +++ src/util/wbuf.rs | 122 + src/util/win_acl.rs | 467 ++ src/util/win_attrs.rs | 128 + src/util/win_xattr.rs | 210 + 87 files changed, 34800 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/rust.yml create mode 100644 .gitignore create mode 100644 CNAME create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 docs/fselect.1 create mode 100644 docs/terminology.svg create mode 100644 docs/usage.md create mode 100644 fselect-completion.bash create mode 100644 resources/test/audio/silent-35s.mp3 create mode 100644 resources/test/audio/silent.wav create mode 100644 resources/test/image/rect.svg create mode 100644 resources/test/image/rust-logo-blk.bmp create mode 100644 resources/test/image/rust-logo-blk.gif create mode 100644 resources/test/image/rust-logo-blk.jpeg create mode 100644 resources/test/image/rust-logo-blk.jpg create mode 100644 resources/test/image/rust-logo-blk.png create mode 100644 resources/test/image/rust-logo-blk.svg create mode 100644 resources/test/image/rust-logo-blk.tiff create mode 100644 resources/test/image/rust-logo-blk.webp create mode 100644 resources/test/image/rust-logo-blk_corrupted.svg create mode 100644 resources/test/video/rust-logo-blk.mkv create mode 100644 resources/test/video/rust-logo-blk.mp4 create mode 100644 src/config.rs create mode 100644 src/expr.rs create mode 100644 src/field/content_handlers.rs create mode 100644 src/field/context.rs create mode 100644 src/field/dispatch.rs create mode 100644 src/field/exif_handlers.rs create mode 100644 src/field/git_handlers.rs create mode 100644 src/field/hash_handlers.rs create mode 100644 src/field/media_handlers.rs create mode 100644 src/field/metadata_handlers.rs create mode 100644 src/field/mod.rs create mode 100644 src/field/mode_handlers.rs create mode 100644 src/field/path_handlers.rs create mode 100644 src/fileinfo.rs create mode 100644 src/function.rs create mode 100644 src/ignore/docker.rs create mode 100644 src/ignore/hg.rs create mode 100644 src/ignore/mod.rs create mode 100644 src/lexer.rs create mode 100644 src/main.rs create mode 100644 src/mode.rs create mode 100644 src/operators.rs create mode 100644 src/output/csv.rs create mode 100644 src/output/flat.rs create mode 100644 src/output/html.rs create mode 100644 src/output/json.rs create mode 100644 src/output/mod.rs create mode 100644 src/parser.rs create mode 100644 src/query.rs create mode 100644 src/searcher.rs create mode 100644 src/util/acl.rs create mode 100644 src/util/app_dirs.rs create mode 100644 src/util/audio.rs create mode 100644 src/util/capabilities.rs create mode 100644 src/util/datetime.rs create mode 100644 src/util/dimensions/image.rs create mode 100644 src/util/dimensions/mkv.rs create mode 100644 src/util/dimensions/mod.rs create mode 100644 src/util/dimensions/mp4.rs create mode 100644 src/util/dimensions/svg.rs create mode 100644 src/util/duration/mkv.rs create mode 100644 src/util/duration/mod.rs create mode 100644 src/util/duration/mp4.rs create mode 100644 src/util/error.rs create mode 100644 src/util/everything.rs create mode 100644 src/util/extattrs.rs create mode 100644 src/util/git.rs create mode 100644 src/util/glob.rs create mode 100644 src/util/greek.rs create mode 100644 src/util/japanese.rs create mode 100644 src/util/mod.rs create mode 100644 src/util/plocate.rs create mode 100644 src/util/top_n.rs create mode 100644 src/util/variant.rs create mode 100644 src/util/wbuf.rs create mode 100644 src/util/win_acl.rs create mode 100644 src/util/win_attrs.rs create mode 100644 src/util/win_xattr.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d3c3bf1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,161 @@ +name: release + +on: + release: + types: [ published ] + workflow_dispatch: + inputs: + tag: + required: true + type: string + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: ${{ matrix.artifact }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # ----- Linux (glibc) ----- + - artifact: fselect-x86_64-linux + target: x86_64-unknown-linux-gnu + os: ubuntu-latest + cross: true + fmt: gz + - artifact: fselect-aarch64-linux + target: aarch64-unknown-linux-gnu + os: ubuntu-latest + cross: true + fmt: gz + + # ----- Linux (musl, fully static) ----- + - artifact: fselect-x86_64-linux-musl + target: x86_64-unknown-linux-musl + os: ubuntu-latest + cross: true + fmt: gz + - artifact: fselect-aarch64-linux-musl + target: aarch64-unknown-linux-musl + os: ubuntu-latest + cross: true + fmt: gz + + # ----- Windows ----- + - artifact: fselect-x86_64-win + target: x86_64-pc-windows-msvc + os: windows-latest + cross: false + fmt: zip + + # ----- macOS ----- + - artifact: fselect-x86_64-mac + target: x86_64-apple-darwin + os: macos-13 # Intel runner + cross: false + fmt: gz + - artifact: fselect-aarch64-mac + target: aarch64-apple-darwin + os: macos-latest # Apple Silicon runner + cross: false + fmt: gz + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross + if: matrix.cross + uses: taiki-e/install-action@v2 + with: + tool: cross + + - name: Build + shell: bash + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --locked --features git,git2/vendored-libgit2 --target ${{ matrix.target }} + else + cargo build --release --locked --features git,git2/vendored-libgit2 --target ${{ matrix.target }} + fi + + - name: Package + shell: bash + run: | + if [ "${{ matrix.fmt }}" = "zip" ]; then + 7z a "${{ matrix.artifact }}.zip" "./target/${{ matrix.target }}/release/fselect.exe" + else + gzip -c "./target/${{ matrix.target }}/release/fselect" > "${{ matrix.artifact }}.gz" + fi + + - name: Upload to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: ${{ matrix.artifact }}.${{ matrix.fmt }} + + deb-package: + name: deb (x86_64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deb + uses: taiki-e/install-action@v2 + with: + tool: cargo-deb + + - name: Build .deb package + run: cargo deb --features git,git2/vendored-libgit2 --output target/debian/ + + - name: Upload to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: target/debian/*.deb + + # Fedora/RHEL/openSUSE .rpm package (x86_64): fselect--1.x86_64.rpm + rpm-package: + name: rpm (x86_64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-generate-rpm + run: cargo install cargo-generate-rpm --locked + + - name: Build release binary + run: cargo build --release --locked --features git,git2/vendored-libgit2 + + - name: Strip binary + run: strip -s target/release/fselect + + - name: Build .rpm package + run: cargo generate-rpm + + - name: Upload to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: target/generate-rpm/*.rpm diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..c26a9d8 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,56 @@ +name: build + +on: + push: + branches: [ master ] + paths: + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + pull_request: + branches: [ master ] + paths: + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + + build: + strategy: + matrix: + os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/cache@v5 + id: cache-deps + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose + - name: Build test image and run tests in Docker + if: matrix.os == 'ubuntu-latest' + run: | + cargo build --release + mkdir docker-test && cp target/release/fselect docker-test/ + cat > docker-test/Dockerfile <"] +description = "Find files with SQL-like queries" +keywords = ["find", "files", "sql", "query", "tool"] +categories = ["filesystem", "command-line-utilities", "command-line-interface"] +documentation = "https://github.com/jhspetersson/fselect/blob/master/docs/usage.md" +homepage = "https://github.com/jhspetersson/fselect" +repository = "https://github.com/jhspetersson/fselect" +readme = "README.md" +license = "MIT OR Apache-2.0" +edition = "2024" + +[features] +default = ["git", "users", "update-notifications", "interactive"] +git = ["dep:git2"] +update-notifications = ["dep:update-informer"] +users = ["dep:uzers"] +interactive = ["dep:rustyline", "dep:directories"] +everything = [] +plocate = [] + +[dependencies] +bytecount = "0.6" +chrono = "0.4" +chrono-english = "0.1" +csv = "1.0" +directories = { version = "6.0", optional = true } +git2 = { version = "0.21", default-features = false, optional = true } +human-time = "0.1.6" +humansize = "2.0" +imagesize = "0.14" +kamadak-exif = "0.6" +lofty = "0.24" +lscolors = { version = "0.21", features = [ "nu-ansi-term" ] } +matroska = "0.30" +memchr = "2" +mp4parse = "0.17" +nu-ansi-term = "0.50" +rand = "0.10" +rbase64 = "2.0" +regex = "1.1" +rustyline = { version = "18", optional = true } +serde = "1.0" +serde_derive = "1.0" +serde_json = "1.0" +sha1 = "0.11" +sha2 = "0.11" +sha3 = "0.12" +svg = "0.18" +toml = "1" +tree_magic_mini = { version = "3.0", features = [ "with-gpl-data" ] } +update-informer = { version = "1.1.0", optional = true } +wana_kana = "5.0" +zip = "8" + +[target.'cfg(unix)'.dependencies] +uzers = { version = "0.12", optional = true } +xattr = "1.0" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Security_Authorization", "Win32_Security", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader"] } + +[profile.release] +lto = true +codegen-units = 1 + +[package.metadata.deb] +section = "utility" +extended-description = """\ +* SQL-like (not real SQL, but highly relaxed!) grammar easily understandable by humans +* complex queries, compare results in several directories with subqueries +* aggregate, statistics, date, and other functions +* search within archives +* .gitignore, .hgignore, and .dockerignore support (experimental) +* search by width and height of images, EXIF metadata +* search by audio metadata (MP3, FLAC, Ogg, Opus, M4A, WAV, and more) +* search by extended file attributes, POSIX ACLs, and Linux capabilities +* search by file hashes +* search by MIME type +* shortcuts to common file types +* interactive mode +* support for plocate and Everything indexes for lightning-fast search +* various output formatting (CSV, JSON, and others)""" + +[package.metadata.generate-rpm] +release = "1" +assets = [ + { source = "target/release/fselect", dest = "/usr/bin/fselect", mode = "755" }, + { source = "LICENSE-MIT", dest = "/usr/share/doc/fselect/LICENSE-MIT", mode = "644", doc = true }, + { source = "LICENSE-APACHE", dest = "/usr/share/doc/fselect/LICENSE-APACHE", mode = "644", doc = true }, + { source = "README.md", dest = "/usr/share/doc/fselect/README.md", mode = "644", doc = true }, +] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0066941 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM rust:latest + +WORKDIR /usr/src/fselect +COPY . . + +RUN cargo install --locked --path . + +CMD ["cargo", "test", "--locked" , "--verbose", "--all"] diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee51923 --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +# fselect +Find files with SQL-like queries + +[![Crates.io](https://img.shields.io/crates/v/fselect.svg)](https://crates.io/crates/fselect) +[![build](https://github.com/jhspetersson/fselect/actions/workflows/rust.yml/badge.svg)](https://github.com/jhspetersson/fselect/actions/workflows/rust.yml) + +### Why use fselect? + +While it doesn't tend to fully replace traditional `find` and `ls`, **fselect** has these nice features: + +* SQL-like (not real SQL, but highly relaxed!) grammar easily understandable by humans +* complex queries, compare results in several directories with [subqueries](docs/usage.md#subqueries-for-in-and-exists) +* aggregate, statistics, date, and other [functions](docs/usage.md#functions) +* search within archives +* `.gitignore`, `.hgignore`, and `.dockerignore` support (experimental) +* search by width and height of images, EXIF metadata +* search by audio metadata (MP3, FLAC, Ogg Vorbis, Opus, M4A/AAC, WAV, AIFF, APE, WavPack, Musepack, Speex) +* search by extended file attributes, POSIX ACLs, and Linux capabilities +* search by file hashes +* search by MIME type +* shortcuts to common file types +* [interactive mode](docs/usage.md#interactive-mode) +* support for `plocate` and `Everything` indexes for lightning-fast search +* various output formatting (CSV, JSON, and others) + +More is under way! + +### Installation + +#### Latest release from source + +* Install [Rust with Cargo](https://www.rust-lang.org/en-US/install.html) and its dependencies to build a binary +* Run `cargo install fselect` + +You can optionally build with fast, index-backed search support: [voidtools *Everything*](https://www.voidtools.com/) +on Windows (`--features everything`, then pass `--everything`) or [*plocate*](https://plocate.sesse.net/) +on Linux (`--features plocate`, then pass `--plocate`). See the +[usage guide](docs/usage.md#index-backed-search-everything--plocate) for details. + +#### Debian/Ubuntu + +[deb package](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect_0.10.2-1_amd64.deb) + +#### Fedora/RHEL/openSUSE + +[rpm package](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-0.10.2-1.x86_64.rpm) + +#### Arch Linux + +[AUR package](https://aur.archlinux.org/packages/fselect/), thanks to [@asm0dey](https://github.com/asm0dey) + +[AUR bin package](https://aur.archlinux.org/packages/fselect-bin/), thanks to [@4censord](https://github.com/4censord) + +#### NixOS + +[`fselect` in `nixpkgs`](https://github.com/filalex77/nixpkgs/blob/1eced92263395896c10cea69e5f60e8be5f43aeb/pkgs/tools/misc/fselect/default.nix), thanks to [@filalex77](https://github.com/filalex77) + +#### Other Linux + +* x86_64: [glibc](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-linux.gz) · [musl (static)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-linux-musl.gz) +* aarch64: [glibc](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-aarch64-linux.gz) · [musl (static)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-aarch64-linux-musl.gz) + +#### Windows 64bit + +A precompiled [binary](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-win.zip) + +#### Windows via winget + +* Install [winget](https://github.com/microsoft/winget-cli) +* Run `winget install -e --id fselect.fselect` + +#### Windows via Chocolatey + +* Install [Chocolatey](https://chocolatey.org/install) +* Run `choco install fselect` + +#### Windows via Scoop + +* Install [Scoop](https://scoop.sh) +* Run `scoop install fselect` + +#### macOS + +Precompiled binaries are available at GitHub downloads (`gunzip`, then `chmod +x fselect`): + +* [Intel (x86_64)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-mac.gz) +* [Apple Silicon (aarch64)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-aarch64-mac.gz) + +#### Mac via Homebrew + +* Install [brew](https://brew.sh) +* Run `brew install fselect` + +#### Mac via MacPorts + +* Install [MacPorts](https://www.macports.org) +* Run: + ``` + sudo port selfupdate + sudo port install fselect + ``` + +### Usage + + fselect [ARGS] COLUMN[, COLUMN...] [from ROOT[, ROOT...]] [where EXPR] [group by COLUMNS] [order by COLUMNS] [limit N] [offset N] [into FORMAT] + +### Interactive mode + + fselect -i + +### Documentation + +[Detailed description of all the supported features.](docs/usage.md) + +### Examples + +Find temporary or config files (full path and size): + + fselect size, path from /home/user where name = '*.cfg' or name = '*.tmp' + +Windows users may omit the quotes: + + fselect size, path from C:\Users\user where name = *.cfg or name = *.tmp + +Or put all the arguments into the quotes like this: + + fselect "name from /home/user/tmp where size > 0" + +Search within a directory name with spaces (backticks are also supported): + + fselect "name from '/home/user/dir with spaces' where size > 0" + fselect "name from `/home/user/dir with spaces` where size > 0" + +Or simply escape the single quote: + + fselect name from \'/home/user/dir with spaces\' where size gt 0 + +Specify the file size, get an absolute path, and add it to the results: + + cd /home/user + fselect size, abspath from ./tmp where size gt 2g + fselect fsize, abspath from ./tmp where size = 5m + fselect hsize, abspath from ./tmp where size lt 8k + fselect name, size from ./tmp where size between 5mb and 6mb + +More complex query: + + fselect "name from /tmp where (name = *.tmp and size = 0) or (name = *.cfg and size > 1000000)" + +You can use subqueries: + + fselect "name from /test1 where size > 100 and size in (select size from /test2 where name in (select name from /test3 where modified in (select modified from /test4 where size < 200)))" + fselect "name, path, size from /data as data where exists (select * from /backup as backup where backup.name = data.name)" + +A subquery can also be used in the `FROM` clause as the source of the outer query: + + fselect "src.name, src.size from (select path from /projects depth 2 where size > 100) as src where src.name like '%.rs'" + +Aggregate functions (you can use curly braces if you want and even combine them with the regular parentheses): + + fselect "MIN(size), MAX{size}, AVG(size), SUM{size}, COUNT(*) from /home/user/Downloads" + +Formatting functions: + + fselect "LOWER(name), UPPER(name), LENGTH(name), YEAR(modified) from /home/user/Downloads" + +Get the year of the oldest file: + + fselect "MIN(YEAR(modified)) from /home/user" + +Use single quotes if you need to address files with spaces: + + fselect "path from '/home/user/Misc stuff' where name != 'Some file'" + +Regular expressions of [Rust flavor](https://docs.rs/regex/1.1.0/regex/#syntax) are supported: + + fselect name from /home/user where path =~ '.*Rust.*' + +Negate regular expressions: + + fselect "name from . where path !=~ '^\./config'" + +Simple globs expand automatically and work with `=` and `!=` operators: + + fselect name from /home/user where path = '*Rust*' + +Classic LIKE: + + fselect "path from /home/user where name like '%report-2018-__-__???'" + +Exact match operators to search with regexps disabled: + + fselect "path from /home/user where name === 'some_*_weird_*_name'" + +Find files by date: + + fselect path from /home/user where created = 2017-05-01 + fselect path from /home/user where modified = today + fselect path from /home/user where accessed = yesterday + fselect "path from /home/user where modified = 'apr 1'" + fselect "path from /home/user where modified = 'last fri'" + +Be more specific to match all files created at an interval between 3PM and 4PM: + + fselect path from /home/user where created = '2017-05-01 15' + +And even more specific: + + fselect path from /home/user where created = '2017-05-01 15:10' + fselect path from /home/user where created = '2017-05-01 15:10:30' + +Date and time intervals are possible (find everything updated since May 1st): + + fselect path from /home/user where modified gte 2017-05-01 + +Default is the current directory: + + fselect path, size where name = '*.jpg' + +Search within multiple locations: + + fselect path from /home/user/oldstuff, /home/user/newstuff where name = '*.jpg' + +With minimum and/or maximum depth specified (`depth` is a synonym for `maxdepth`): + + fselect path from /home/user/oldstuff depth 5 where name = '*.jpg' + fselect path from /home/user/oldstuff mindepth 2 maxdepth 5, /home/user/newstuff depth 10 where name = '*.jpg' + +Optionally follow symlinks: + + fselect path, size from /home/user symlinks where name = '*.jpg' + +Search within archives (currently only zip-archives are supported): + + fselect path, size from /home/user archives where name = '*.jpg' + +Or in combination: + + fselect size, path from /home/user depth 5 archives symlinks where name = '*.jpg' limit 100 + +Enable `.gitignore` or `.hgignore` support: + + fselect size, path from /home/user/projects gitignore where name = '*.cpp' + fselect size, path from /home/user/projects git where name = '*.cpp' + fselect size, path from /home/user/projects hgignore where name = '*.py' + +Search by image dimensions: + + fselect CONCAT(width, 'x', height), path from /home/user/photos where width gte 2000 or height gte 2000 + +Find square images: + + fselect path from /home/user/Photos where width = height + +Find images with a known name part but unknown extension: + + fselect path from /home/user/projects where name = "*RDS*" and width gte 1 + +Find old-school rap MP3 files: + + fselect duration, path from /home/user/music where genre = Rap and bitrate = 320 and mp3_year lt 2000 + +Shortcuts to common file extensions: + + fselect path from /home/user where is_archive = true + fselect path, mime from /home/user where is_audio = 1 + fselect path, mime from /home/user where is_book != false + +Even simpler way of using boolean columns: + + fselect path from /home/user where is_doc + fselect path from /home/user where is_image + fselect path from /home/user where is_video + +Find files with dangerous permissions: + + fselect mode, path from /home/user where other_write or other_exec + fselect mode, path from /home/user where other_all + +Simple glob-like expressions or even regular expressions in file mode are possible: + + fselect mode, path from /home/user where mode = '*rwx' + fselect mode, path from /home/user where mode =~ '.*rwx$' + +Find files by owner's uid or gid: + + fselect uid, gid, path from /home/user where uid != 1000 or gid != 1000 + +Or by owner's or group's name: + + fselect user, group, path from /home/user where user = mike or group = mike + +Find special files: + + fselect name from /usr/bin where suid + fselect path from /tmp where is_sticky + fselect path from /tmp where is_pipe + fselect path from /tmp where is_socket + +Find files with xattrs, check if a particular xattr exists, or get its value: + + fselect "path, has_xattrs, has_xattr(user.test), xattr(user.test) from /home/user" + +Include arbitrary text as columns: + + fselect "name, ' has size of ', size, ' bytes'" + +Group results: + + fselect "ext, count(*) from /tmp group by ext" + fselect "ext, count(*) from /tmp group by 1" + fselect "mime, ext, count(*) from /tmp group by 1, 2" + +Order results: + + fselect path from /tmp order by size desc, name + fselect modified, fsize, path from ~ order by 1 desc, 3 + +Finally, limit the results: + + fselect name from /home/user/samples limit 5 + +Format output: + + fselect size, path from /home/user limit 5 into json + fselect size, path from /home/user limit 5 into csv + fselect size, path from /home/user limit 5 into html + +### License + +MIT/Apache-2.0 + +--- + +Supported by [JetBrains IDEA](https://jb.gg/OpenSourceSupport) open source license diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..7df0796 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`jhspetersson/fselect` +- 原始仓库:https://github.com/jhspetersson/fselect +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/docs/fselect.1 b/docs/fselect.1 new file mode 100644 index 0000000..9e1af85 --- /dev/null +++ b/docs/fselect.1 @@ -0,0 +1,1189 @@ +.TH FSELECT 1 +.SH NAME +fselect \- find files with SQL-like queries +.SH SYNOPSIS +.B fselect +.RB [ \-\-nocolor ] +.RB [ \-\-no\-errors ] +.RB [ \-\-us\-dates ] +.RB [ \-\-config +.IR FILE ] +.PP +.B fselect +.I COLUMN +.RI [ ", " COLUMN " ..."] +.RB [ from +.IR ROOT +.RI [ ", " ROOT " ..."]] +.RB [ where +.IR EXPR ] +.RB [ group +.B by +.IR COLUMNS ] +.RB [ order +.B by +.IR COLUMNS ] +.RB [ limit +.IR N ] +.RB [ offset +.IR N ] +.RB [ into +.IR FORMAT ] +.PP +.B fselect \-i +.SH DESCRIPTION +.B fselect +is a command-line utility for finding files using an SQL-like query language. +The +.B fselect +command itself acts as the +.B select +keyword (i.e., +.IR "file select" ). +An optional +.B select +keyword at the beginning of the query is accepted but not required. +.PP +Query columns may be file attributes, arbitrary string literals (quoted if they contain spaces), +function calls, or arithmetic expressions. +.PP +If +.B from +is omitted, the current directory is searched. +Multiple search roots may be listed, separated by commas. +Each root may have its own set of options (depth, symlinks, archives, etc.). +.PP +Queries containing +.BR > , +.BR < , +parentheses, or shell metacharacters such as +.BR * " and " ? +should be enclosed in double quotes to prevent shell interpretation. +.PP +Commas between column names are optional. +String literals do not need quotes unless they contain spaces or shell metacharacters. +Functions with no arguments do not require parentheses. +Curly braces may be used instead of parentheses. +.SH OPTIONS +.TP +.BR \-\-interactive ", " \-i +Run in interactive mode. +.TP +.BI \-\-config " FILE" +Specify an alternative configuration file location. +.TP +.BR \-\-nocolor ", " \-\-no\-color +Disable colorized output. +.TP +.B \-\-no\-errors +Suppress error messages. +.TP +.BR \-\-us\-dates +Interpret ambiguous dates in US format (MM/DD) instead of the default UK format (DD/MM). +.TP +.BR \-\-help ", " \-h +Show help and exit. +.SH COLUMNS AND FIELDS +.SS File naming +.TP +.B name +File name with extension. +.TP +.BR filename " or " fname +File name without extension. +.TP +.BR extension " or " ext +File extension only (without the leading dot). +.TP +.B path +Path relative to the search root. +.TP +.B abspath +Absolute path of the file. +.TP +.BR directory " or " dirname " or " dir +Directory portion of the path, relative to the search root. +.TP +.B absdir +Absolute directory portion of the path. +.TP +.BR link_target " or " symlink_target +Target path of the symlink, or empty if the file is not a symlink. +.SS File naming terminology +Given a search root of +.B /home/user/projects +and a file at +.BR /home/user/projects/foobar/content/readme.md : +.PP +.RS +.nf +\fBabspath\fR /home/user/projects/foobar/content/readme.md +\fBabsdir\fR /home/user/projects/foobar/content +\fBpath\fR foobar/content/readme.md +\fBdir\fR foobar/content +\fBname\fR readme.md +\fBfilename\fR readme +\fBext\fR md +.fi +.RE +.SS Size +.TP +.B size +Size of the file in bytes. +.TP +.BR fsize " or " hsize +Human-readable size with unit suffix. +.SS Timestamps +.TP +.B accessed +Time the file was last accessed (YYYY-MM-DD HH:MM:SS). +.TP +.B created +File creation time (YYYY-MM-DD HH:MM:SS). +.TP +.B modified +Time the file was last modified (YYYY-MM-DD HH:MM:SS). +.SS Type tests (boolean) +.TP +.B is_dir +True if path is a directory. +.TP +.B is_file +True if path is a regular file. +.TP +.B is_symlink +True if path is a symbolic link. +.TP +.BR is_broken_symlink " or " is_broken_link +True if path is a symbolic link whose target does not exist. +.TP +.BR is_pipe " or " is_fifo +True if path is a FIFO or named pipe. +.TP +.BR is_char " or " is_character +True if path is a character special file. +.TP +.B is_block +True if path is a block special file. +.TP +.B is_socket +True if path is a socket. +.TP +.B is_hidden +True if file is hidden (starts with a dot on Unix). +.TP +.B is_empty +True if file is empty or directory contains no entries. +.TP +.B is_shebang +True if file begins with a shebang +.RB ( #! ). +.TP +.B is_binary +True if MIME type detection identifies the file as binary. +.TP +.B is_text +True if MIME type detection identifies the file as text. +.SS Git +.TP +.B is_git_repo +True if the directory contains a +.B .git +subdirectory or file (i.e., is the root of a git repository). +.TP +.BR is_git_tracked ", " git_tracked +True if the file is tracked by git. +.TP +.BR is_gitignored ", " is_git_ignored +True if the file is ignored by git. +.TP +.B git_status +Git status of the file: +.BR clean ", " modified ", " staged ", " untracked ", " conflicted ", or " ignored . +.TP +.B git_branch +Current branch of the git repository containing the file. +.TP +.BR git_last_commit_hash ", " git_commit_hash +Hash of the last commit that touched the file. +.TP +.BR git_last_commit_date ", " git_commit_date +Date of the last commit that touched the file. +.TP +.BR git_last_commit_author ", " git_commit_author +Author of the last commit that touched the file. +.SS Permissions +.TP +.B mode +Full permission string, similar to the first column of +.BR "ls \-la" . +.TP +.BR uid " / " gid +Numeric user ID / group ID of the owner. +.TP +.BR user " / " group +Name of the owner / owner's group. +Available only on Unix when the +.B users +feature is enabled. +.TP +.BR user_read ", " user_write ", " user_exec ", " user_all " / " user_rwx +Per-owner permission bits. +.TP +.BR group_read ", " group_write ", " group_exec ", " group_all " / " group_rwx +Per-group permission bits. +.TP +.BR other_read ", " other_write ", " other_exec ", " other_all " / " other_rwx +Permission bits for other users. +.TP +.BR suid " or " is_suid +SUID bit. +.TP +.BR sgid " or " is_sgid +SGID bit. +.TP +.BR is_sticky " or " sticky +Sticky bit. +.SS Unix/Linux-specific +.TP +.B device +Device number the file resides on. +.TP +.B rdev +Device ID for special files (character and block devices). +.TP +.B inode +Inode number. +.TP +.B blocks +Number of 512-byte blocks allocated. +.TP +.B blksize, block_size +Preferred block size for filesystem I/O in bytes. +.TP +.B hardlinks +Number of hard links. +.TP +.B atime +Last access time as a Unix timestamp (seconds since epoch). +.TP +.B atime_nsec +Nanosecond component of the last access time. +.TP +.B mtime +Last modification time as a Unix timestamp (seconds since epoch). +.TP +.B mtime_nsec +Nanosecond component of the last modification time. +.TP +.B ctime +Last status change time as a Unix timestamp (seconds since epoch). +.TP +.B ctime_nsec +Nanosecond component of the last status change time. +.TP +.B has_xattrs +True if the file has extended attributes (xattrs). +.TP +.B xattr_count +Number of extended attributes (xattrs) on the file, or alternate data streams on Windows. +.TP +.B extattrs +Extended file attribute flags as reported by +.BR lsattr (1) +(single-letter codes). +.TP +.B has_extattrs +True if any +.B extattrs +flag is set. +.TP +.B acl +All ACL entries in standard form, comma-separated. +On Linux, returns POSIX ACL entries in +.BR getfacl (1) +format. +On Windows, returns explicit DACL entries as +.BR type : trustee : permissions +(e.g., +.BR allow:BUILTIN\eAdministrators:full ). +Permission labels: +.BR full ", " modify ", " rx ", " read ", " write , +or a hex value for non-standard masks. +Available only on Linux and Windows. +.TP +.B has_acl +True if the file has POSIX ACL entries beyond standard Unix permissions +or explicit (non-inherited) Windows DACL entries. +.TP +.B default_acl +All default POSIX ACL entries in standard form, comma-separated. +Available only on Linux. +.TP +.B has_default_acl +True if the directory has default POSIX ACL entries. +.TP +.BR has_capabilities " or " has_caps +True if the file has Linux capabilities. +Available only on Linux. +.TP +.BR capabilities " or " caps +Linux capabilities string assigned to the file. +.SS Media +.TP +.BR width " / " height +Width and height of an image or MP4 video in pixels. +.TP +.B duration +Duration of an audio file in seconds. +.TP +.BR mp3_bitrate " or " bitrate +Audio bitrate in kbps. +.TP +.BR mp3_freq " or " freq +Audio sampling frequency. +.TP +.BR mp3_title " or " title +Track title from audio metadata. +.TP +.BR mp3_artist " or " artist +Artist name from audio metadata. +.TP +.BR mp3_album " or " album +Album name from audio metadata. +.TP +.BR mp3_genre " or " genre +Genre from audio metadata. +.TP +.BR mp3_comment " or " comment +Comment from audio metadata. +.TP +.BR mp3_track " or " track +Track number from audio metadata (e.g. 4 or 4/9). +.TP +.BR mp3_disc " or " disc +Disc number (part of a set) from audio metadata (e.g. 1 or 1/2). +.TP +.BR mp3_year " or " audio_year +Year from audio metadata. +.SS EXIF metadata +.TP +.B exif_datetime +Date and time recorded in EXIF data. +.TP +.BR exif_datetime_original " or " exif_dto +Original date and time when the photo was taken. +.TP +.BR exif_altitude " or " exif_alt +GPS altitude. +.TP +.BR exif_latitude " or " exif_lat +GPS latitude. +.TP +.BR exif_longitude " or " exif_lng " or " exif_lon +GPS longitude. +.TP +.BR exif_make " / " exif_model +Camera manufacturer and model. +.TP +.B exif_software +Software used to create the image. +.TP +.B exif_version +Version of EXIF metadata. +.TP +.BR exif_exposure_time " or " exif_exptime +Exposure time. +.TP +.B exif_aperture +Aperture value. +.TP +.B exif_shutter_speed +Shutter speed. +.TP +.BR exif_f_number " or " exif_f_num +F-number. +.TP +.BR exif_iso_speed " or " exif_iso +ISO speed (EXIF 2.3 ISOSpeed tag). +.TP +.BR exif_sensitivity " or " exif_photo_sensitivity +Photographic sensitivity (ISO). +.TP +.BR exif_focal_length " or " exif_focal_len +Focal length. +.TP +.BR exif_lens_make " / " exif_lens_model +Lens manufacturer and model. +.TP +.BR exif_description " or " exif_desc +Image description. +.TP +.B exif_artist +Artist or photographer name. +.TP +.B exif_copyright +Copyright information. +.TP +.B exif_orientation +Image orientation. +.TP +.B exif_flash +Flash status. +.TP +.B exif_color_space +Color space. +.TP +.BR exif_exposure_program " or " exif_exp_program +Exposure program. +.TP +.BR exif_exposure_bias " or " exif_exp_bias +Exposure bias value. +.TP +.BR exif_white_balance " or " exif_wb +White balance mode. +.TP +.B exif_metering_mode +Metering mode. +.TP +.BR exif_scene_type " or " exif_scene +Scene capture type. +.TP +.B exif_contrast +Contrast setting. +.TP +.B exif_saturation +Saturation setting. +.TP +.B exif_sharpness +Sharpness setting. +.TP +.BR exif_body_serial " or " exif_serial +Camera body serial number. +.TP +.B exif_lens_serial +Lens serial number. +.TP +.BR exif_user_comment " or " exif_comment +User comment. +.TP +.BR exif_image_width " or " exif_width +Image width from EXIF. +.TP +.BR exif_image_height " or " exif_height +Image height from EXIF. +.TP +.B exif_max_aperture +Max aperture value of the lens. +.TP +.BR exif_digital_zoom " or " exif_dzoom +Digital zoom ratio. +.SS MIME and file type shortcuts +.TP +.B mime +MIME type string. +.TP +.B line_count +Number of lines in a text file. +.TP +.BR word_count " or " words +Number of whitespace-separated words in a text file. +.TP +.BR char_count " or " chars +Number of characters in a text file. +.TP +.B encoding +Detected text encoding (e.g. ASCII, UTF-8, UTF-16LE), or empty for binary files. +.TP +.B has_bom +True if the file begins with a byte-order mark (BOM). +.TP +.BR line_ending " or " line_endings " or " eol +Line ending style of a text file (LF, CRLF, CR, Mixed, or empty). +.TP +.B is_archive +True if extension matches a known archive format +(.7z, .bz2, .gz, .rar, .tar, .xz, .zip, and others). +.TP +.B is_audio +True if extension matches a known audio format +(.aac, .flac, .mp3, .ogg, .wav, .wma, and others). +.TP +.B is_book +True if extension matches a known book format +(.epub, .fb2, .mobi, .pdf, and others). +.TP +.B is_doc +True if extension matches a known document format +(.doc, .docx, .odt, .pdf, .ppt, .xls, and others). +.TP +.B is_font +True if extension matches a known font format +(.otf, .ttf, .woff, .woff2, and others). +.TP +.B is_image +True if extension matches a known image format +(.bmp, .gif, .jpeg, .jpg, .png, .svg, .webp, and others). +.TP +.B is_source +True if extension matches a known source code format +(.c, .cpp, .go, .java, .js, .py, .rs, .ts, and others). +.TP +.B is_video +True if extension matches a known video format +(.avi, .mkv, .mov, .mp4, .webm, .wmv, and others). +.SS File hashes +.TP +.B sha1 +SHA-1 digest. +.TP +.BR sha2_256 " or " sha256 +SHA-256 digest. +.TP +.BR sha2_512 " or " sha512 +SHA-512 digest. +.TP +.BR sha3_512 " or " sha3 +SHA3-512 digest. +.SH FUNCTIONS +.SS Aggregate functions +Queries using these functions return a single result row. +.TP +.B AVG +Average value. +.TP +.B COUNT +Number of rows. +.TP +.B MAX +Maximum value. +.TP +.B MIN +Minimum value. +.TP +.B SUM +Sum of all values. +.TP +.BR STDDEV_POP " / " STDDEV " / " STD +Population standard deviation. +.TP +.B STDDEV_SAMP +Sample standard deviation. +.TP +.BR VAR_POP " / " VARIANCE +Population variance. +.TP +.B VAR_SAMP +Sample variance. +.SS Date functions +.TP +.BR CURRENT_DATE " / " CUR_DATE " / " CURDATE +Current date. +.TP +.BR CURRENT_TIME " / " CUR_TIME " / " CURTIME +Current local time (HH:MM:SS). +.TP +.BR CURRENT_TIMESTAMP " / " NOW +Current local timestamp (YYYY-MM-DD HH:MM:SS). +.TP +.B DAY +Day of the month. +.TP +.B MONTH +Month of the year. +.TP +.B YEAR +Year. +.TP +.BR DOW " / " DAYOFWEEK +Day of the week (1 = Sunday, 2 = Monday, \&...). +.TP +.B DAYNAME +Name of the day of the week (e.g., Monday, Tuesday). +.TP +.BR DOY " / " DAYOFYEAR +Day of the year (1\(en366). +.TP +.BR DATE_ADD " / " DATEADD "(date, days)" +Add +.I days +to a date and return the resulting datetime. +.TP +.BR DATE_SUB " / " DATESUB "(date, days)" +Subtract +.I days +from a date and return the resulting datetime. +.TP +.BR DATE_DIFF " / " DATEDIFF "(date1, date2)" +Number of days between +.I date1 +and +.IR date2 . +.TP +.BR FROM_UNIXTIME "(timestamp)" +Convert a Unix timestamp (seconds since epoch) to a datetime string. +.TP +.BR LAST_DAY " / " LAST_DATE "(date)" +Last day of the month for the given date. +.TP +.BR EXTRACT "(unit, date)" +Extract a date/time part from +.IR date . +The +.I unit +must be a quoted string. Supported units: +.BR year ", " quarter ", " month ", " week " (ISO), " +.BR day ", " hour ", " minute ", " second ", " +.BR dow " (1=Sun..7=Sat), " +.BR isodow " (1=Mon..7=Sun), " +.BR doy ", " epoch +(Unix timestamp). +.TP +.BR DATE_TRUNC " / " DATETRUNC "(unit, date)" +Truncate +.I date +to a unit boundary, returned as +.RB \(lq YYYY\-MM\-DD\ HH:MM:SS \(rq. +The +.I unit +must be a quoted string. Supported units: +.BR year ", " quarter ", " month ", " +.BR week " (truncates to Monday), " +.BR day ", " hour ", " minute ", " second . +.SS String functions +.TP +.BR LENGTH " / " LEN +Length of the string value. +.TP +.BR LOWER " / " LOWERCASE " / " LCASE +Convert to lowercase. +.TP +.BR UPPER " / " UPPERCASE " / " UCASE +Convert to uppercase. +.TP +.B INITCAP +First letter of each word uppercase, rest lowercase. +.TP +.BR TO_BASE64 " / " BASE64 +Encode to Base64. +.TP +.B FROM_BASE64 +Decode from Base64. +.TP +.B CONCAT +Concatenate expression values. +.TP +.B CONCAT_WS +Concatenate expression values with a delimiter. +.TP +.BR LOCATE " / " POSITION "(str, substr [, pos])" +Position of +.I substr +in +.IR str . +.TP +.BR SUBSTRING " / " SUBSTR "(str, pos [, len])" +Substring starting at +.IR pos . +Negative +.I pos +counts from the end. +.TP +.BR REPLACE "(str, from, to)" +Replace all occurrences. +.TP +.B TRIM +Strip whitespace from both ends. +.TP +.B LTRIM +Strip whitespace from the left. +.TP +.B RTRIM +Strip whitespace from the right. +.SS Numeric functions +.TP +.BR BIN " / " HEX " / " OCT +Convert integer to binary, hexadecimal, or octal. +.TP +.B ABS +Absolute value. +.TP +.BR POWER " / " POW +Raise to a power. +.TP +.B SQRT +Square root. +.TP +.B LOG +Common logarithm. +.TP +.B LN +Natural logarithm. +.TP +.B EXP +Euler's number raised to the given power. +.TP +.B LEAST +Smallest of the given values. +.TP +.B GREATEST +Largest of the given values. +.TP +.B PI +The constant π. +.TP +.B FLOOR +Largest integer not greater than the value. +.TP +.BR CEIL " / " CEILING +Smallest integer not less than the value. +.TP +.B ROUND +Nearest integer. +.TP +.BR RANDOM " / " RAND +Random integer. +With no argument: 0 to max int. +With one argument: 0 to +.IR arg . +With two arguments: from +.I arg1 +to +.IR arg2 . +.SS Character detection functions +.TP +.BR CONTAINS_JAPANESE " / " JAPANESE +True if the string contains Japanese characters. +.TP +.BR CONTAINS_HIRAGANA " / " HIRAGANA +True if the string contains Hiragana characters. +.TP +.BR CONTAINS_KATAKANA " / " KATAKANA +True if the string contains Katakana characters. +.TP +.BR CONTAINS_KANA " / " KANA +True if the string contains Kana characters (Hiragana or Katakana). +.TP +.BR CONTAINS_KANJI " / " KANJI +True if the string contains Kanji characters. +.TP +.BR CONTAINS_GREEK " / " GREEK +True if the string contains Greek characters. +.SS Other functions +.TP +.B CONTAINS +True if the file contains the given string. +.TP +.B COALESCE +First non-empty value from the argument list. +.TP +.BR FORMAT_TIME " / " PRETTY_TIME +Human-readable duration (e.g., +.IR "2min 26s" ). +.TP +.BR FORMAT_SIZE " / " FORMAT_FILESIZE +Formatted file size. +The optional second argument is a format specifier such as +.B "%.2 d" +(two decimal places, 1000-based units). +.SS Extended attribute and ACL functions +.TP +.BR HAS_XATTR "(name)" +True if the named xattr exists. +.TP +.BR XATTR "(name)" +Value of the named xattr. +.TP +.BR HAS_EXTATTR "(flag)" +True if the given +.BR lsattr (1) +flag is set. +Available only on Linux. +.TP +.BR HAS_ACL_ENTRY "(entry)" +True if the specified ACL entry exists. +Entry format: +.BR type : qualifier +(e.g., +.BR user:john , +.BR group:staff ). +.TP +.BR ACL_ENTRY "(entry)" +Permissions for the specified ACL entry. +.TP +.BR HAS_DEFAULT_ACL_ENTRY "(entry)" +True if the specified default ACL entry exists. +.TP +.BR DEFAULT_ACL_ENTRY "(entry)" +Permissions for the specified default ACL entry. +.TP +.BR HAS_CAPABILITY " / " HAS_CAP "(cap)" +True if the file has the given Linux capability. +.SS User functions +Available only on Unix when the +.B users +feature is enabled. +.TP +.BR CURRENT_UID () +Current real UID. +.TP +.BR CURRENT_USER () +Name of the current real UID. +.TP +.BR CURRENT_GID () +Current primary GID. +.TP +.BR CURRENT_GROUP () +Name of the current primary GID. +.SH FILE SIZE UNITS +Size comparisons and the +.B fsize +column understand the following unit suffixes: +.PP +.RS +.nf +\fBt\fR or \fBtib\fR tebibyte 1024^4 bytes +\fBtb\fR terabyte 1000^4 bytes +\fBg\fR or \fBgib\fR gibibyte 1024^3 bytes +\fBgb\fR gigabyte 1000^3 bytes +\fBm\fR or \fBmib\fR mebibyte 1024^2 bytes +\fBmb\fR megabyte 1000^2 bytes +\fBk\fR or \fBkib\fR kibibyte 1024 bytes +\fBkb\fR kilobyte 1000 bytes +.fi +.RE +.SH SEARCH ROOTS +Each search root may be followed by one or more options: +.TP +.BI mindepth " N" +Minimum search depth. Depth 1 skips the root directory itself and starts at its children. +.TP +.BI maxdepth " N" +Maximum search depth. +Depth 1 searches only the root directory. +Depth 2 also searches immediate subdirectories. +Synonym: +.BR depth . +.TP +.BR symlinks " / " sym +Follow symbolic links. Default is not to follow. +.TP +.BR archives " / " arc +Search inside archives (zip only). Default is not to search archives. +.TP +.BR gitignore " / " git +Respect +.B .gitignore +files. +.TP +.BR hgignore " / " hg +Respect +.B .hgignore +files. +.TP +.BR dockerignore " / " dock +Respect +.B .dockerignore +files. +.TP +.BR nogitignore " / " nogit +Disable +.B .gitignore +parsing. +.TP +.BR nohgignore " / " nohg +Disable +.B .hgignore +parsing. +.TP +.BR nodockerignore " / " nodock +Disable +.B .dockerignore +parsing. +.TP +.B dfs +Depth-first traversal. +.TP +.B bfs +Breadth-first traversal (default). +.TP +.BR regexp " / " rx +Treat the root path as a regular expression matching against multiple directories. +.SH OPERATORS +.SS Comparison operators +.TP +.BR = " / " == " / " eq +Equal (with glob expansion and regular expression matching for strings). +.TP +.BR != " / " <> " / " ne +Not equal. +.TP +.BR === " / " eeq +Exact equal (no glob or regex expansion). +.TP +.BR !== " / " ene +Exact not-equal. +.TP +.BR > " / " gt +Greater than. +.TP +.BR >= " / " gte " / " ge +Greater than or equal. +.TP +.BR < " / " lt +Less than. +.TP +.BR <= " / " lte " / " le +Less than or equal. +.TP +.BR =~ " / " ~= " / " regexp " / " rx +Match regular expression. +.TP +.BR !=~ " / " !~= " / " notrx +Does not match regular expression. +.TP +.B like +SQL LIKE pattern ( +.B % +matches any sequence, +.B _ +matches one character). +.TP +.B notlike +Negation of +.BR like . +.TP +.B between +Range check: +.IB column " between " val1 " and " val2 +.TP +.B in +Set membership: +.IB column " in (" val1 ", " val2 ", ...)" +or +.IB column " in (subquery)" . +.TP +.B exists +Row existence test: +.B exists (subquery) +.SS Arithmetic operators +.TP +.BR + " / " plus +Addition. +.TP +.BR - " / " minus +Subtraction. +.TP +.BR * " / " mul +Multiplication. +.TP +.BR / " / " div +Division. +.TP +.BR % " / " mod +Modulo. +.SS Logical operators +.TP +.B and +Logical conjunction. +.TP +.B or +Logical disjunction. +.TP +.B not +Logical negation. May appear before an operator or a condition. +.SH DATE AND TIME SPECIFIERS +When the +.B = +or +.B != +operator is used with an inexact date, it is treated as an interval: +.PP +.RS +.TP +.B 2017-05-01 +The entire day (00:00:00 to 23:59:59). +.TP +.B "2017-05-01 15" +One hour (15:00:00 to 15:59:59). +.TP +.B "2017-05-01 15:10" +One minute (15:10:00 to 15:10:59). +.RE +.PP +Other comparison operators accept more flexible date expressions: +.B today , +.B yesterday , +.B "last fri" , +.B "apr 1" , +.B 2017-05-01 , +and numeric relative offsets (e.g., +.B -2 +means two days ago). +.PP +Dates default to UK locale (DD/MM). Use +.B \-\-us\-dates +or +.B us_dates = true +in the config file to switch to US format. +The safest representation is ISO 8601: +.BR "YYYY-MM-DD HH:MM:SS" . +.SH REGULAR EXPRESSIONS +Rust-flavor regular expressions are used (see +.BR regex (7) +or the online documentation at https://docs.rs/regex). +.SH SUBQUERIES +Subqueries may be used with the +.BR in ", " "not in" ", " exists ", and " "not exists" +operators. +To reference columns from an outer query in a correlated subquery, bind the search root with an alias using the +.B as +keyword: +.PP +.RS +.EX +fselect "name, path, size from /data as data + where exists (select * from /backup as backup + where backup.name = data.name)" +.EE +.RE +.SH OUTPUT FORMATS +Specified with the +.B into +keyword at the end of the query. +.TP +.B tabs +Tab-separated columns (default). +.TP +.B lines +Each column on a separate line. +.TP +.B list +Columns separated by a NUL byte, similar to +.B find \-print0 +(suitable for +.BR "xargs \-0" ). +.TP +.B csv +Comma-separated values. +.TP +.B json +JSON array of result objects. +.TP +.B html +HTML document containing a table. +.SH CONFIGURATION FILE +On Linux: +.RS +.B ~/.config/fselect/config.toml +.RE +.PP +On Windows: +.RS +.B %APPDATA%\ejhspetersson\efselect\econfig.toml +.RE +.PP +If no config file is found on the standard paths, +.B fselect +also looks for a config file next to the executable. +A custom path may be supplied with +.BR \-\-config . +.SH INTERACTIVE MODE +Start with +.BR "fselect \-i" . +In interactive mode: +.IP \(bu +Queries are entered directly without quoting for the shell. +.IP \(bu +Shell metacharacters need not be escaped. +.IP \(bu +Command history is available via the up and down arrow keys. +.IP \(bu +.B pwd +prints the current directory; +.B cd +changes it. +.IP \(bu +.B "errors off" +suppresses error reporting. +.IP \(bu +Exit with +.BR quit , +.BR exit , +Ctrl+C, or Ctrl+D. +.SH ENVIRONMENT +.TP +.B LS_COLORS +Determines how search results are colorized; see +.BR dircolors (1). +.TP +.B NO_COLOR +When set (to any value), disables all colorized output. +.SH EXIT STATUS +.TP +.B 0 +Success. +.TP +.B 1 +I/O error during directory traversal or file reading. +.TP +.B 2 +Query parse error, or a fatal error while evaluating the query +(e.g. an invalid root alias). +.SH EXAMPLES +.TP +Find temporary and config files, showing size and full path: +$ fselect size, path from /home/user where name = '*.cfg' or name = '*.tmp' +.TP +Search in a directory whose name contains spaces: +$ fselect "name from '/home/user/dir with spaces' where size > 0" +.TP +Find large files (over 2 GiB): +$ fselect size, abspath from ./tmp where size gt 2g +.TP +Find files within a size range: +$ fselect name, size from ./tmp where size between 5mb and 6mb +.TP +Complex boolean condition: +$ fselect "name from /tmp where (name = *.tmp and size = 0) or (name = *.cfg and size > 1000000)" +.TP +Find files modified today, ordered by size: +$ fselect path, size from /home/user where modified = today order by size desc +.TP +Find files by regular expression: +$ fselect name from /home/user where path =~ '.*Rust.*' +.TP +Aggregate: total size of downloads: +$ fselect "MIN(size), MAX(size), AVG(size), SUM(size), COUNT(*) from /home/user/Downloads" +.TP +Group files by extension with count: +$ fselect "ext, count(*) from /tmp group by ext" +.TP +Search with depth limit and symlink following: +$ fselect path from /home/user depth 5 symlinks where name = '*.jpg' +.TP +Search respecting .gitignore: +$ fselect size, path from /home/user/projects gitignore where name = '*.cpp' +.TP +Find images by dimensions: +$ fselect path from /home/user/photos where width gte 2000 or height gte 2000 +.TP +Find old rap MP3s: +$ fselect duration, path from /home/user/music where genre = Rap and bitrate = 320 and mp3_year lt 2000 +.TP +Find files with dangerous permissions: +$ fselect mode, path from /home/user where other_write or other_exec +.TP +Find files not present in a backup directory: +$ fselect "name, path from /current where name not in (select name from /backup)" +.TP +Output as JSON: +$ fselect size, path from /home/user limit 5 into json +.TP +Pipe NUL-separated paths to grep: +$ fselect path from /home/user into list | xargs -0 grep foobar +.SH SEE ALSO +.BR find (1), +.BR fd (1), +.BR ls (1), +.BR dircolors (1) diff --git a/docs/terminology.svg b/docs/terminology.svg new file mode 100644 index 0000000..8656cea --- /dev/null +++ b/docs/terminology.svg @@ -0,0 +1,146 @@ + + + + + + + + + + File Naming Terminology + Visual breakdown of path components + + + + + + Search Root: /home/user/projects + + + + + + + /home/user/projects + + + + / + + + + foobar + + + + / + + + + content + + + + / + + + + readme + + + + .md + + + + + + + + + abspath + /home/user/projects/foobar/content/readme.md + + + + + + absdir + /home/user/projects/foobar/content + + + + + + path + foobar/content/readme.md + + + + + + dir + foobar/content + + + + + + name + readme.md + + + + + + filename + + + + + + ext + + + + Legend + + + Root Directory + + + Subdirectory 1 + + + Subdirectory 2 + + + Filename + + + Extension + + + + Absolute directory (without filename) + Path relative to search root + Relative directory + filename + ext + \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..18427fd --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,1099 @@ +# fselect + +Find files with SQL-like queries. + +[Basic usage](#basic-usage) +[Restrictions](#its-not-a-real-sql) +[Columns and fields](#columns-and-fields) +[File naming terminology](#file-naming-terminology) +[Functions](#functions) +[File size units](#file-size-units) +[Search roots](#search-roots) +[Operators](#operators) +[Arithmetic operators](#arithmetic-operators) +[Subqueries in the FROM clause](#subqueries-in-the-from-clause) +[Subqueries for IN and EXISTS](#subqueries-for-in-and-exists) +[Date and time specifiers](#date-and-time-specifiers) +[Regular expressions](#regular-expressions) +[MIME and file types](#mime-and-file-types) +[Audio support](#audio-support) +[File hashes](#file-hashes) +[Output formats](#output-formats) +[Configuration file](#configuration-file) +[Bash completion](#bash-completion) +[Command-line arguments](#command-line-arguments) +[Index-backed search](#index-backed-search-everything--plocate) +[Interactive mode](#interactive-mode) +[Environment variables](#environment-variables) +[Exit values](#exit-values) + +### Basic usage + + fselect [ARGS] COLUMN[, COLUMN...] [from ROOT[, ROOT...]] [where EXPR] [group by COLUMNS] [order by COLUMNS] [limit N] [offset N] [into FORMAT] + +You write an SQL-like query, that's it. + +**fselect** command itself is like a first keyword (`select`, i.e., *file select*). +But if you put one more `select` behind occasionally, that's not a problem. + +Next you put columns you are interested in. It could be a file name or path, size, modification date, etc. +See the full list of possible columns. You can add columns with arbitrary text (put in quotes if it contains spaces). +A few functions (aggregating and formatting) are there for your service. You can use arithmetic expressions when it makes sense. + +Where to search? Specify with `from` keyword. You can list one or more directories separated with comma. +If you leave the `from`, then current directory will be processed. + +What to search? Use `where` with any number of conditions. + +Group results with `group by` followed by one or more columns. Like `order by`, this clause +accepts positional numeric shortcuts that refer to columns from the `select` list, for example +`group by 1` or `group by 1, 2`. An aggregate function in the `select` list is not required: +`select ext from /home/user group by ext` returns one row per distinct extension, like +`SELECT DISTINCT` in SQL. + +Order results like in real SQL with `order by`. All columns are supported for ordering by, +as well as `asc`/`desc` parameters and positional numeric shortcuts. + +Limiting search results is possible with `limit` and `offset`. Formatting options are supported with `into` keyword. + +If you want to use operators containing `>` or `<`, +put the whole query into the double quotes. +This will protect a query from the shell and output redirection. +The same applies to queries with parentheses or `*`, `?` and other special shell +metacharacters. + +It's ok to use any metacharacters in interactive mode. + +### It's not a real SQL + +Directories to search at are listed with comma separators. +In a real SQL, such syntax would make a cross-product. Here it means just search at A, next at B, and so on. + +You can use curly braces instead of the regular parentheses! This helps to avoid a few of the shell pitfalls a little bit. +Functions with no arguments don't require parentheses at all. + +String literals don't really need quotes. +You will need to put them just in case you query something with spaces inside. +And yes, you should use quotes for glob-patterns or regular expressions in the query +on Linux or macOS to prevent parameter expansion from the shell. +If you are on Windows, feel free to omit most of the quotes. + +Commas for column separation aren't needed as well. Column aliasing (with or without `as` keyword) is not supported. + +`where` section can contain short syntax conditions for boolean columns (like `is_audio` or `other_write`). + +`into` keyword specifies output format, not output table. + +Joins and unions are not supported (yet?). +Subqueries have only limited support: in `IN` / `EXISTS` predicates, and as the source of a `FROM` clause. + +### Columns and fields + +| Column | Meaning | Comment | +|----------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------| +| `name` | Returns the name (with extension) of the file | | +| `filename` or `fname` | Returns the file name without extension | | +| `extension` or `ext` | Returns the extension of the file | | +| `path` | Returns the relative path of the file | | +| `abspath` | Returns the absolute path of the file | | +| `directory` or `dirname` or `dir` | Returns the directory of the file | | +| `absdir` | Returns the absolute directory of the file | | +| `size` | Returns the size of the file in bytes | | +| `fsize` or `hsize` | Returns the size of the file accompanied with the unit | | +| `uid` | Returns the UID of the owner | | +| `gid` | Returns the GID of the owner's group | | +| `accessed` | Returns the time the file was last accessed (YYYY-MM-DD HH:MM:SS) | | +| `created` | Returns the file creation date (YYYY-MM-DD HH:MM:SS) | Windows, macOS, and Linux (kernel 4.11+ with ext4/btrfs/XFS) | +| `modified` | Returns the time the file was last modified (YYYY-MM-DD HH:MM:SS) | | +| `atime` | Returns the last access time as a Unix timestamp (seconds since epoch) | Available only on Unix | +| `atime_nsec` | Returns the nanosecond component of the last access time | Available only on Unix | +| `mtime` | Returns the last modification time as a Unix timestamp (seconds since epoch) | Available only on Unix | +| `mtime_nsec` | Returns the nanosecond component of the last modification time | Available only on Unix | +| `ctime` | Returns the last status change time as a Unix timestamp (seconds since epoch) | Available only on Unix | +| `ctime_nsec` | Returns the nanosecond component of the last status change time | Available only on Unix | +| `is_dir` | Returns a boolean signifying whether the file path is a directory | | +| `is_file` | Returns a boolean signifying whether the file path is a file | | +| `is_symlink` | Returns a boolean signifying whether the file path is a symlink | | +| `link_target` or `symlink_target` | Returns the target path of the symlink, or an empty value if the file is not a symlink | | +| `is_broken_symlink` or `is_broken_link` | Returns a boolean signifying whether the file path is a symlink whose target does not exist | | +| `is_pipe` or `is_fifo` | Returns a boolean signifying whether the file path is a FIFO or pipe file | | +| `is_char` or `is_character` | Returns a boolean signifying whether the file path is a character device or character special file | | +| `is_block` | Returns a boolean signifying whether the file path is a block or block special file | | +| `is_socket` | Returns a boolean signifying whether the file path is a socket file | | +| `is_hidden` | Returns a boolean signifying whether the file is a hidden file (e.g., files that start with a dot on *nix) | | +| `has_xattrs` | Returns a boolean signifying whether the file has extended attributes or alternate data streams on Windows | | +| `xattr_count` | Returns the count of extended attributes on the file or alternate data streams on Windows | | +| `extattrs` | Returns the extended file attributes as a string of flag letters (chattr/lsattr flags on Linux, NTFS attribute letters on Windows) | Available only on Linux and Windows | +| `has_extattrs` | Returns a boolean signifying whether the file has any extended file attributes set | Available only on Linux and Windows | +| `acl` | Returns all ACL entries in standard form (POSIX on Linux, DACL on Windows) | Available only on Linux and Windows | +| `has_acl` | Returns a boolean signifying whether the file has POSIX ACL entries beyond standard Unix permissions or Windows explicit ACEs | Available only on Linux and Windows | +| `default_acl` | Returns all default ACL entries in standard form (default POSIX ACLs on Linux, inheritable ACEs on Windows) | Available only on Linux and Windows | +| `has_default_acl` | Returns a boolean signifying whether the directory has default ACL entries (default POSIX ACLs on Linux, inheritable ACEs on Windows) | Available only on Linux and Windows | +| `has_capabilities` or `has_caps` | Returns a boolean signifying whether the file has capabilities | Available only on Linux | +| `capabilities` or `caps` | Returns a string describing Linux capabilities assigned to a file | Available only on Linux | +| `device` | Returns the code of device the file is stored on | Available only on Unix | +| `rdev` | Returns the device ID for special files (character and block devices) | Available only on Unix | +| `inode` | Returns the number of inode | Available only on Linux | +| `blocks` | Returns the number of blocks (512 bytes) the file occupies | Available only on Linux | +| `blksize` or `block_size` | Returns the preferred block size for filesystem I/O in bytes | Available only on Unix | +| `hardlinks` | Returns the number of hardlinks of the file | Available only on Linux | +| `mode` | Returns the permissions of the owner, group, and everybody (similar to the first field in `ls -la`) | | +| `user` | Returns the name of the owner for this file | Available only on *nix platforms with `users` feature enabled | +| `user_read` | Returns a boolean signifying whether the file can be read by the owner | | +| `user_write` | Returns a boolean signifying whether the file can be written by the owner | | +| `user_exec` | Returns a boolean signifying whether the file can be executed by the owner | | +| `user_all` | Returns a boolean signifying whether the file can be fully accessed by the owner | | +| `group` | Returns the name of the owner's group for this file | Available only on *nix platforms with `users` feature enabled | +| `group_read` | Returns a boolean signifying whether the file can be read by the owner's group | | +| `group_write` | Returns a boolean signifying whether the file can be written by the owner's group | | +| `group_exec` | Returns a boolean signifying whether the file can be executed by the owner's group | | +| `group_all` | Returns a boolean signifying whether the file can be fully accessed by the group | | +| `other_read` | Returns a boolean signifying whether the file can be read by others | | +| `other_write` | Returns a boolean signifying whether the file can be written by others | | +| `other_exec` | Returns a boolean signifying whether the file can be executed by others | | +| `other_all` | Returns a boolean signifying whether the file can be fully accessed by the others | | +| `suid` or `is_suid` | Returns a boolean signifying whether the file permissions have a SUID bit set | | +| `sgid` or `is_sgid` | Returns a boolean signifying whether the file permissions have a SGID bit set | | +| `sticky` or `is_sticky` | Returns a boolean signifying whether the file permissions have a sticky bit set | | +| `width` | Returns the number of pixels along the width of the photo or MP4 file | | +| `height` | Returns the number of pixels along the height of the photo or MP4 file | | +| `mime` | Returns MIME type of the file | | +| `is_binary` | Returns a boolean signifying whether the file has binary contents | | +| `is_text` | Returns a boolean signifying whether the file has text contents | | +| `line_count` | Returns a number of lines in a text file | | +| `word_count` or `words` | Returns the number of whitespace-separated words in a text file | | +| `char_count` or `chars` | Returns the number of characters in a text file | | +| `encoding` | Returns the detected text encoding of the file (e.g., ASCII, UTF-8, UTF-16LE), or an empty value for binary files | | +| `has_bom` | Returns a boolean signifying whether the file begins with a byte-order mark (BOM) | | +| `line_ending` or `line_endings` or `eol` | Returns the line ending style of a text file (LF, CRLF, CR, Mixed, or empty) | | +| `exif_datetime` | Returns date and time of taken photo | | +| `exif_datetime_original` or `exif_dto` | Returns original date and time when the photo was taken | | +| `exif_altitude` or `exif_alt` | Returns GPS altitude of taken photo | | +| `exif_latitude` or `exif_lat` | Returns GPS latitude of taken photo | | +| `exif_longitude` or `exif_lng` or `exif_lon` | Returns GPS longitude of taken photo | | +| `exif_make` | Returns name of the camera manufacturer | | +| `exif_model` | Returns camera model | | +| `exif_software` | Returns software name with which the photo was taken | | +| `exif_version` | Returns the version of EXIF metadata | | +| `exif_exposure_time` or `exif_exptime` | Returns exposure time of the photo taken | | +| `exif_aperture` | Returns aperture value of the photo taken | | +| `exif_shutter_speed` | Returns shutter speed of the photo taken | | +| `exif_f_number` or `exif_f_num` | Returns F-number of the photo taken | | +| `exif_iso_speed` or `exif_iso` | Returns ISO speed of the photo taken (EXIF 2.3 ISOSpeed tag) | | +| `exif_sensitivity` or `exif_photo_sensitivity` | Returns photographic sensitivity (ISO) of the photo taken | | +| `exif_focal_length` or `exif_focal_len` | Returns focal length of the photo taken | | +| `exif_lens_make` | Returns lens manufacturer used to take the photo | | +| `exif_lens_model` | Returns lens model used to take the photo | | +| `exif_description` or `exif_desc` | Returns image description from EXIF metadata | | +| `exif_artist` | Returns the artist or photographer name | | +| `exif_copyright` | Returns the copyright information | | +| `exif_orientation` | Returns the image orientation | | +| `exif_flash` | Returns the flash status when the photo was taken | | +| `exif_color_space` | Returns the color space of the image | | +| `exif_exposure_program` or `exif_exp_program` | Returns the exposure program used | | +| `exif_exposure_bias` or `exif_exp_bias` | Returns the exposure bias value | | +| `exif_white_balance` or `exif_wb` | Returns the white balance mode | | +| `exif_metering_mode` | Returns the metering mode | | +| `exif_scene_type` or `exif_scene` | Returns the scene capture type | | +| `exif_contrast` | Returns the contrast setting | | +| `exif_saturation` | Returns the saturation setting | | +| `exif_sharpness` | Returns the sharpness setting | | +| `exif_body_serial` or `exif_serial` | Returns the camera body serial number | | +| `exif_lens_serial` | Returns the lens serial number | | +| `exif_user_comment` or `exif_comment` | Returns the user comment from EXIF metadata | | +| `exif_image_width` or `exif_width` | Returns the image width from EXIF metadata | | +| `exif_image_height` or `exif_height` | Returns the image height from EXIF metadata | | +| `exif_max_aperture` | Returns the max aperture value of the lens | | +| `exif_digital_zoom` or `exif_dzoom` | Returns the digital zoom ratio | | +| `mp3_title` or `title` | Returns the title of the audio file taken from the file's metadata | | +| `mp3_album` or `album` | Returns the album name of the audio file taken from the file's metadata | | +| `mp3_artist` or `artist` | Returns the artist of the audio file taken from the file's metadata | | +| `mp3_genre` or `genre` | Returns the genre of the audio file taken from the file's metadata | | +| `mp3_comment` or `comment` | Returns the comment of the audio file taken from the file's metadata | | +| `mp3_track` or `track` | Returns the track number of the audio file taken from the file's metadata (e.g., 4 or 4/9) | | +| `mp3_disc` or `disc` | Returns the disc number (part of a set) of the audio file taken from the file's metadata (e.g., 1 or 1/2) | | +| `mp3_year` or `audio_year` | Returns the year of the audio file taken from the file's metadata | | +| `mp3_freq` or `freq` | Returns the sampling rate of the audio file in Hz | | +| `mp3_bitrate` or `bitrate` | Returns the bitrate of the audio file in kbps | | +| `duration` | Returns the duration in seconds of an audio file, or an MP4/Matroska/WebM video | | +| `is_shebang` | Returns a boolean signifying whether the file starts with a shebang (#!) | | +| `is_git_repo` | Returns a boolean signifying whether the directory contains a `.git` subdirectory or file | | +| `is_git_tracked` or `git_tracked` | Returns a boolean signifying whether the file is tracked by git | Requires the `git` feature (enabled by default) | +| `is_gitignored` or `is_git_ignored` | Returns a boolean signifying whether the file is ignored by git | Requires the `git` feature (enabled by default) | +| `git_status` | Returns the git status of the file (`clean`, `modified`, `staged`, `untracked`, `conflicted`, or `ignored`) | Requires the `git` feature (enabled by default) | +| `git_branch` | Returns the current branch of the git repository containing the file | Requires the `git` feature (enabled by default) | +| `git_last_commit_hash` or `git_commit_hash` | Returns the hash of the last commit that touched the file | Requires the `git` feature (enabled by default) | +| `git_last_commit_date` or `git_commit_date` | Returns the date of the last commit that touched the file | Requires the `git` feature (enabled by default) | +| `git_last_commit_author` or `git_commit_author` | Returns the author of the last commit that touched the file | Requires the `git` feature (enabled by default) | +| `is_empty` | Returns a boolean signifying whether the file is empty or the directory is empty | | +| `is_archive` | Returns a boolean signifying whether the file is an archival file | [default extensions](#ext_archive) | +| `is_audio` | Returns a boolean signifying whether the file is an audio file | [default extensions](#ext_audio) | +| `is_book` | Returns a boolean signifying whether the file is a book | [default extensions](#ext_book) | +| `is_doc` | Returns a boolean signifying whether the file is a document | [default extensions](#ext_doc) | +| `is_font` | Returns a boolean signifying whether the file is a font | [default extensions](#ext_font) | +| `is_image` | Returns a boolean signifying whether the file is an image | [default extensions](#ext_image) | +| `is_source` | Returns a boolean signifying whether the file is source code | [default extensions](#ext_source) | +| `is_video` | Returns a boolean signifying whether the file is a video file | [default extensions](#ext_video) | +| `sha1` | Returns SHA-1 digest of a file | | +| `sha2_256` or `sha256` | Returns SHA2-256 digest of a file | | +| `sha2_512` or `sha512` | Returns SHA2-512 digest of a file | | +| `sha3_512` or `sha3` | Returns SHA-3 digest of a file | | + +### File naming terminology + +Let's see how all these are different: + + fselect abspath, absdir, path, dir, name, filename, ext from /home/user/projects where is_file + +| Column | Value | Comment | +|------------|----------------------------------------------|----------------------------------------------------------------| +| `abspath` | /home/user/projects/foobar/content/readme.md | Absolute path includes everything | +| `absdir` | /home/user/projects/foobar/content | Absolute directory includes everything except the last segment | +| `path` | foobar/content/readme.md | Path is relative to the search root `/home/user/projects` | +| `dir` | foobar/content | Relative directory | +| `name` | readme.md | `name` = `filename` + `ext` | +| `filename` | readme | +| `ext` | md | + +![File Naming Terminology](terminology.svg) + +### Functions + +#### Aggregate functions + +Queries using these functions return only one result row. + +| Function | Meaning | Example | +|---------------------------|---------------------------------------------------------------|------------------------------------------------------| +| AVG | Average of all values | `select avg(size) from /home/user/Downloads` | +| COUNT | Number of rows; `count(col)` skips rows with empty values | `select count(*) from /home/user/Downloads` | +| MAX | Maximum value (numeric, date, or string) | `select max(size) from /home/user/Downloads` | +| MIN | Minimum value (numeric, date, or string) | `select min(size) from /home/user where size gt 0` | +| SUM | Sum of all values | `select sum(size) from /home/user/Downloads` | +| STDDEV_POP, STDDEV or STD | Population standard deviation, the square root of variance | `select stddev_pop(size) from /home/user/Downloads` | +| STDDEV_SAMP | Sample standard deviation, the square root of sample variance | `select stddev_samp(size) from /home/user/Downloads` | +| VAR_POP or VARIANCE | Population variance | `select var_pop(size) from /home/user/Downloads` | +| VAR_SAMP | Sample variance | `select var_samp(size) from /home/user/Downloads` | + +#### Date functions + +Used mostly for formatting results. + +| Function | Meaning | Example | +|-------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------| +| CURRENT_DATE or CUR_DATE or CURDATE | Returns current date | `select modified, path where modified = CURDATE()` | +| CURRENT_TIME or CUR_TIME or CURTIME | Returns current local time (HH:MM:SS) | `select CURRENT_TIME()` | +| CURRENT_TIMESTAMP or NOW | Returns current local timestamp (YYYY-MM-DD HH:MM:SS) | `select NOW()` | +| DAY | Extract day of the month | `select day(modified) from /home/user/Downloads` | +| MONTH | Extract month of the year | `select month(name) from /home/user/Downloads` | +| YEAR | Extract year of the date | `select year(name) from /home/user/Downloads` | +| DOW or DAYOFWEEK | Returns day of the week (1 - Sunday, 2 - Monday, etc.) | `select name, modified, dow(modified) from /home/user/projects/FizzBuzz` | +| DAYNAME | Returns the name of the day of the week | `select dayname(modified) from /home/user/Downloads` | +| DOY or DAYOFYEAR | Returns the day of the year (1-366) | `select dayofyear(modified) from /home/user/Downloads` | +| DATE_ADD or DATEADD | Add days to a date | `select "date_add(modified, 30) from /home/user"` | +| DATE_SUB or DATESUB | Subtract days from a date | `select "date_sub(modified, 7) from /home/user"` | +| DATE_DIFF or DATEDIFF | Number of days between two dates | `select "date_diff(modified, created) from /home/user"` | +| FROM_UNIXTIME | Convert a Unix timestamp to a datetime string | `select "from_unixtime(mtime) from /home/user"` | +| LAST_DAY or LAST_DATE | Last day of the month for a given date | `select "last_day(modified) from /home/user"` | +| EXTRACT | Extract a date/time part (year, quarter, month, week, day, hour, minute, second, dow, isodow, doy, epoch) — unit must be quoted | `select "extract('year', modified) from /home/user"` | +| DATE_TRUNC or DATETRUNC | Truncate a date to a unit (year, quarter, month, week, day, hour, minute, second) — unit must be quoted | `select "date_trunc('month', modified) from /home/user"` | + +#### User functions + +These are only available on Unix platforms when `users` feature has been enabled during compilation. + +| Function | Meaning | Example | +|----------------|----------------------------|--------------------------| +| CURRENT_UID | Current real UID | `select CURRENT_UID()` | +| CURRENT_USER | Current real UID's name | `select CURRENT_USER()` | +| CURRENT_GID | Current primary GID | `select CURRENT_GID()` | +| CURRENT_GROUP | Current primary GID's name | `select CURRENT_GROUP()` | + +#### Xattr functions + +Used to check if a particular xattr exists or to get its value. +Supported platforms are Linux, macOS, FreeBSD, and NetBSD. + +| Function | Meaning | Example | +|------------------------------|----------------------------------------------------------------------|---------------------------------------------------------------------| +| HAS_XATTR | Check if xattr exists | `select "name, has_xattr(user.test) from /home/user"` | +| XATTR | Get value of xattr | `select "name, xattr(user.test) from /home/user"` | +| HAS_EXTATTR | Check if a specific extended file attribute flag is set (Linux and Windows) | `select "name from / where has_extattr('i')"` | +| HAS_ACL_ENTRY | Check if a specific ACL entry exists (Linux and Windows) | `select "name from /data where has_acl_entry('user:john')"` | +| ACL_ENTRY | Get permissions of a specific ACL entry (Linux and Windows) | `select "name, acl_entry('group:staff') from /data"` | +| HAS_DEFAULT_ACL_ENTRY | Check if a specific default ACL entry exists (Linux and Windows) | `select "name from /data where has_default_acl_entry('user:john')"` | +| DEFAULT_ACL_ENTRY | Get permissions of a specific default ACL entry (Linux and Windows) | `select "name, default_acl_entry('group:staff') from /data"` | +| HAS_CAPABILITY or HAS_CAP | Check if given Linux capability exists for the file | `select "name, has_cap('cap_bpf') from /home/user"` | + +#### ACLs + +**fselect** can read and display Access Control Lists on both Linux and Windows. + +##### POSIX ACLs (Linux) + +On Linux, **fselect** reads POSIX Access Control Lists stored as `system.posix_acl_access` or `system.posix_acl_default` +extended attributes. It is useful for auditing file permissions beyond the standard Unix owner/group/other model. + +The `has_acl` field returns true when a file has extended ACL entries (named users, named groups, +or a mask entry) beyond the basic owner/group/other permissions. + +The `acl` field returns all ACL entries in standard `getfacl`-like format, comma-separated: +`user::rwx,user:john:rw-,group::r-x,group:staff:r--,mask::rwx,other::r--` + +Use `has_acl_entry` and `acl_entry` to query specific entries. The entry specifier uses the format +`type:qualifier` where type is `user` (or `u`), `group` (or `g`), `mask` (or `m`), or `other` (or `o`). +An empty qualifier refers to the owning user/group. Examples: + + fselect name from /data where has_acl = true + fselect "name, acl from /data where has_acl = true" + fselect "name from /data where has_acl_entry('user:john')" + fselect "name, acl_entry('group:staff') from /data" + +When the `users` feature is enabled, uid/gid values are resolved to usernames/group names. +Otherwise, numeric IDs are used in the output. + +##### Windows DACLs + +On Windows, **fselect** reads the Discretionary Access Control List (DACL) via the Win32 Security API. +Only explicit (non-inherited) ACEs are shown. + +The `has_acl` field returns true when a file has at least one explicit (non-inherited) ACE in its DACL. + +The `acl` field returns all explicit ACEs as comma-separated entries in the format +`type:trustee:permissions`, where: + +- **type** is `allow` or `deny` +- **trustee** is the resolved account name (e.g., `BUILTIN\Administrators`, `NT AUTHORITY\SYSTEM`) +- **permissions** is one of `full`, `modify`, `rx`, `read`, `write`, or a hex value for non-standard masks + +Example output: `allow:BUILTIN\Administrators:full,allow:NT AUTHORITY\SYSTEM:full,allow:BUILTIN\Users:rx` + +The `default_acl` and `has_default_acl` fields report a directory's *inheritable* ACEs (those +carrying the object- or container-inherit flag), which are the Windows analogue of POSIX default +ACLs — the entries that propagate to newly created child objects. + +Use `has_acl_entry` and `acl_entry` (and their `default_acl_entry` counterparts) to query a +single trustee. The argument is matched against the trustee either as a full `DOMAIN\Name` or as +a bare account name, case-insensitively: + + fselect name from C:\ where has_acl = true + fselect "name, acl from C:\Users where has_acl = true" + fselect "name, default_acl from C:\Windows where is_dir = true" + fselect "name from C:\data where has_acl_entry('Administrators')" + fselect "name, acl_entry('BUILTIN\Users') from C:\data" + +#### Extended file attributes + +**fselect** can read and query extended file attributes (also known as file flags). On Linux these +are the flags managed with `chattr` and displayed with `lsattr`, read via the `FS_IOC_GETFLAGS` +ioctl on ext2/ext3/ext4, btrfs, and other supporting filesystems. On Windows these are the NTFS +file attributes. + +On Linux the `extattrs` field returns a string of flag letters for each set attribute, using the +same single-letter codes as `lsattr`/`chattr`: +`s` (secure deletion), `u` (undelete), `c` (compress), `S` (synchronous updates), +`i` (immutable), `a` (append only), `d` (no dump), `A` (no atime updates), +`E` (encrypted), `I` (indexed directory), `j` (journal data), `t` (no tail-merging), +`D` (dirsync), `T` (top of directory hierarchy), `e` (extents), `V` (verity), +`C` (no copy-on-write), `x` (DAX), `N` (inline data), `P` (project hierarchy), +`F` (case-insensitive directory). + +On Windows the `extattrs` field returns a string of letters for each set NTFS attribute +(letters follow the `attrib` command where applicable): +`R` (read-only), `H` (hidden), `S` (system), `A` (archive), `T` (temporary), +`P` (sparse file), `L` (reparse point), `C` (compressed), `O` (offline), +`I` (not content indexed), `E` (encrypted), `V` (integrity stream). +Note that the Windows letters are case-sensitive (all upper-case). + +The `has_extattrs` field returns true when any of these attributes are set. +Use the `has_extattr()` function to check for a specific flag: + + fselect name from / where has_extattrs = true + fselect "name, extattrs from /data where has_extattrs = true" + fselect "name from / where has_extattr('i')" + fselect "name, extattrs from C:\data where has_extattr('H')" + +#### String functions + +Used mostly for formatting results. + +| Function | Meaning | Example | +|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------| +| LENGTH or LEN | Length of string value | `select length(name) from /home/user/Downloads order by 1 desc limit 10` | +| LOWER or LOWERCASE or LCASE | Convert value to lowercase | `select lower(name) from /home/user/Downloads` | +| UPPER or UPPERCASE or UCASE | Convert value to uppercase | `select upper(name) from /home/user/Downloads` | +| INITCAP | Returns first letter of each word uppercase, all other letters lowercase | `select initcap('MICHAEL SMITH')` | +| TO_BASE64 or BASE64 | Encode value to Base64 | `select base64(name) from /home/user/Downloads` | +| FROM_BASE64 | Decode value from Base64 | `select from_base64('ZnNlbGVjdCByb2Nrcw==')` | +| CONCAT | Returns concatenated string of expression values | `select CONCAT('Name is ', name, ' size is ', fsize, '!!!') from /home/user/Downloads` | +| CONCAT_WS | Returns concatenated string of expression values with specified delimiter | `select name, fsize, CONCAT_WS('x', width, height) from /home/user/Images` | +| LOCATE or POSITION (str, substr, pos) | Returns position of `substr` in `str` value (optionally starting from `pos`) | `select locate('foo', 'barfoo')` | +| SUBSTRING or SUBSTR (str, pos, len) | Part of `str` value starting from `pos` of (optionally) `len` characters long. Negative `pos` means starting `pos` characters from the end of the string. | `select substr(name, 1, 8) from /home/user/Downloads` | +| REPLACE (str, from, to) | Replace all occurrences of `from` by `to` | `select replace(name, metallica, MetaLLicA) from /home/user/Music/Rock` | +| TRIM | Returns string with whitespaces at the beginning and the end stripped | `select trim(title), trim(artist), trim(album) from /home/user/Music into json` | +| LTRIM | Returns string with whitespaces at the beginning stripped | `select ltrim(title) from /home/user/Music into json` | +| RTRIM | Returns string with whitespaces at the end stripped | `select rtrim(title) from /home/user/Music into json` | + +#### Japanese string functions + +Used for detecting Japanese symbols in file names and such. + +| Function | Meaning | Example | +|-------------------------------|-------------------------------------------------|------------------------------------------------------------| +| CONTAINS_JAPANESE or JAPANESE | Check if string value contains Japanese symbols | `select japanese(name) from /home/user/Downloads` | +| CONTAINS_KANA or KANA | Check if string value contains kana symbols | `select kana(name) from /home/user/Downloads` | +| CONTAINS_HIRAGANA or HIRAGANA | Check if string value contains hiragana symbols | `select contains_hiragana(name) from /home/user/Downloads` | +| CONTAINS_KATAKANA or KATAKANA | Check if string value contains katakana symbols | `select katakana(name) from /home/user/Downloads` | +| CONTAINS_KANJI or KANJI | Check if string value contains kanji symbols | `select kanji(name) from /home/user/Downloads` | + +#### Greek string functions + +Used for detecting Greek symbols in file names and such. + +| Function | Meaning | Example | +|---------------------------|-----------------------------------------------|---------------------------------------------------| +| CONTAINS_GREEK or GREEK | Check if string value contains Greek symbols | `select greek(name) from /home/user/Downloads` | + +#### Other functions + +| Function | Meaning | Example | +|----------------------------|---------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| BIN | Convert integer value to binary representation | `select name, size, bin(size) from /home/user/Downloads` | +| HEX | Convert integer value to hexadecimal representation | `select name, size, hex(size), upper(hex(size)) from /home/user/Downloads` | +| OCT | Convert integer value to octal representation | `select name, size, oct(size) from /home/user/Downloads` | +| ABS | Returns absolute value of the expression | `select abs(-5)` | +| POWER or POW | Raise the value to the specified power | `select pow(2, 3)` | +| SQRT | Returns square root of the value | `select sqrt(25)` | +| LOG | Returns logarithm of the value | `select log(1000)` | +| LN | Returns natural logarithm of the value | `select ln(10)` | +| EXP | Returns Euler's number raised to the power of the value | `select exp(2)` | +| LEAST | Returns the smallest of the expression values | `select least(1, 2, 3)` | +| GREATEST | Returns the largest of the expression values | `select greatest(1, 2, 3)` | +| PI | Returns `pi` (π) constant | `select pi()` | +| FLOOR | Returns the largest integer less than or equal to the value | `select floor(2.5)` | +| CEIL or CEILING | Returns the smallest integer greater than or equal to the value | `select ceil(2.5)` | +| ROUND | Returns the value rounded to the nearest integer, or to a given number of decimal places | `select round(2.5)` or `select round(pi(), 2)` | +| CONTAINS | `true` if file contains string, `false` if not | `select contains(TODO) from /home/user/Projects/foo/src` | +| COALESCE | Returns first nonempty expression value | `select name, size, COALESCE(sha256, '---') from /home/user/Downloads` | +| RANDOM or RAND | Returns random integer (from zero to max int, from zero to *arg*, or from *arg1* to *arg2*) | `select path from /home/user/Music order by RAND()` | +| FORMAT_TIME or PRETTY_TIME | Returns human-readable durations of time in seconds like *2min 26s* | `select format_time(duration) from /home/user/Music` | +| FORMAT_SIZE | Returns formatted size of a file | `select name, FORMAT_SIZE(size, '%.0') from /home/user/Downloads order by size desc limit 10` | + +Let's try `FORMAT_SIZE` with different format specifiers: + +| Specifier | Meaning | Output | +|-----------------------------------|--------------------------------------------------------------------------------|-------------| +| `format_size(1678123)` | Default output | 1.60MiB | +| `format_size(1678123, ' ')` | Put a space before units | 1.60 MiB | +| `format_size(1678123, '%.0')` | Round up decimal part | 2MiB | +| `format_size(1678123, '%.1')` | One place for decimal part | 1.6MiB | +| `format_size(1678123, '%.2')` | Two places for decimal part | 1.60MiB | +| `format_size(1678123, '%.2 ')` | Two places for decimal part, and put a space before units | 1.60 MiB | +| `format_size(1678123, '%.2 d')` | Use decimal divider, e.g. 1000-based units, not 1024-based | 1.68 MB | +| `format_size(1678123, '%.2 c')` | Use conventional format, e.g. 1024-based divider, but display 1000-based units | 1.60 MB | +| `format_size(1678123, '%.2 k')` | Display file size in specified unit, this time in kibibytes | 1638.79 KiB | +| `format_size(1678123, '%.2 ck')` | What is a kibibyte? Gimme conventional unit! | 1638.79 KB | +| `format_size(1678123, '%.0 ck')` | And drop this decimal part! | 1639 KB | +| `format_size(1678123, '%.0 kb')` | Use 1000-based kilobyte | 1678 KB | +| `format_size(1678123, '%.0kb')` | Don't put a space | 1678KB | +| `format_size(1678123, '%.0s')` | Use short units | 2M | +| `format_size(1678123, '%.0 s')` | Use short units with a space | 2 M | + +### File size units + +| Specifier | Meaning | Bytes | +|--------------|----------|---------------------------| +| `t` or `tib` | tebibyte | 1024 * 1024 * 1024 * 1024 | +| `tb` | terabyte | 1000 * 1000 * 1000 * 1000 | +| `g` or `gib` | gibibyte | 1024 * 1024 * 1024 | +| `gb` | gigabyte | 1000 * 1000 * 1000 | +| `m` or `mib` | mebibyte | 1024 * 1024 | +| `mb` | megabyte | 1000 * 1000 | +| `k` or `kib` | kibibyte | 1024 | +| `kb` | kilobyte | 1000 | + + fselect size, path from /home/user/tmp where size gt 2g + fselect fsize, path from /home/user/tmp where size = 5mib + fselect hsize, path from /home/user/tmp where size lt 8kb + +### Search roots + + path [option N] [option] [option] [option...][, path2 [option...]] + +When you put a directory to search at, you can specify some options. + +| Option | Meaning | +|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| mindepth N | Minimum search depth. Default is unlimited. Depth 1 means skip one directory level and search further. | +| maxdepth N | Maximum search depth. Default is unlimited. Depth 1 means search the mentioned directory only. Depth 2 means search mentioned directory and its subdirectories. Synonym is `depth`. | +| symlinks | If specified, search process will follow symlinks. Default is not to follow. Synonym is `sym`. | +| archives | Search within archives. Only zip archives are supported. Default is not to include archived content into the search results. Synonym is `arc`. | +| gitignore | Search respects `.gitignore` files found. Synonym is `git`. | +| hgignore | Search respects `.hgignore` files found. Synonym is `hg`. | +| dockerignore | Search respects `.dockerignore` files found. Synonym is `dock`. | +| nogitignore | Disable `.gitignore` parsing during the search. Synonym is `nogit`. | +| nohgignore | Disable `.hgignore` parsing during the search. Synonym is `nohg`. | +| nodockerignore | Disable `.dockerignore` parsing during the search. Synonym is `nodock`. | +| dfs | Depth-first search mode. | +| bfs | Breadth-first search mode. This is the default. | +| regexp | Use regular expressions to search within multiple roots. Synonym is `rx`. | + +### Operators + +* `=` or `==` or `eq` +* `!=` or `<>` or `ne` +* `===` or `eeq` +* `!==` or `ene` +* `>` or `gt` +* `>=` or `gte` or `ge` +* `<` or `lt` +* `<=` or `lte` or `le` +* `=~` or `~=` or `regexp` or `rx` +* `!=~` or `!~=` or `notrx` +* `like` +* `notlike` +* `between` +* `in` +* `exists` + +### Arithmetic operators + +| Operator | Alias | +|----------|--------| +| + | plus | +| - | minus | +| * | mul | +| / | div | +| % | mod | + +### Subqueries in the `FROM` clause + +The `FROM` clause can take a parenthesized inner query in place of (or alongside) a filesystem path. +The inner query is executed first, and the rows it produces become the input set the outer query +iterates over — its `WHERE`, `SELECT`, `LIMIT`, and `ORDER BY` clauses then run against that set +without any further directory traversal. + +```sql +select name from (select * from /projects depth 2) +``` + +You can attach options like an alias to the subselect root just like a regular root: + +```sql +select src.name, src.size from (select path from /projects depth 2) as src where src.size > 1024 +``` + +A `WHERE` clause inside the subselect filters the input set; a `WHERE` clause outside filters the +outer rows produced from that input set: + +```sql +select name from (select path from /projects depth 2 where size > 100) where name like '%.rs' +``` + +Subselects may be nested. + +### Subqueries for `IN` and `EXISTS` + +Subqueries in **fselect** allow you to nest queries within queries, enabling powerful file search operations that compare results across different directory trees. +Subqueries can be used with the `IN`, `NOT IN`, `EXISTS`, and `NOT EXISTS` operators to create sophisticated filtering logic. + +**Important:** When using subqueries that need to reference the parent query's results, you must bind search roots using aliases with the `AS` keyword. +This creates a correlated subquery where the inner query can reference columns from the outer query. + +> [!CAUTION] +> This feature is still in development. Random queries can fail for no obvious reason. + +#### General Subquery Syntax +```sql +SELECT columns FROM root AS alias +WHERE column operator (SELECT columns2 FROM root2 AS alias2 WHERE condition) +``` + +#### Supported Operators +- `IN` - Tests if a value exists in the subquery result set +- `NOT IN` - Tests if a value does not exist in the subquery result set +- `EXISTS` - Tests if the subquery returns any rows +- `NOT EXISTS` - Tests if the subquery returns no rows + +#### `IN` Operator + +The `IN` operator checks if a value from the outer query matches any value returned by the subquery. + +**Example:** Find files in `/backup` that have the same size as files in `/production`: + +```sql +select name, size from /backup +where size in (select size from /production) +``` + +**Example:** Find files with multiple levels of correlation: + +```sql +select name from /test1 +where size > 100 and size in ( + select size from /test2 + where name in ( + select name from /test3 + where modified in ( + select modified from /test4 + where size < 200 + ) + ) +) +``` + +This query finds files in `/test1` where: +1. Size is greater than 100 bytes +2. Size matches files in `/test2` +3. Those `/test2` files have names matching files in `/test3` +4. Those `/test3` files have modification times matching files in `/test4` (smaller than 200 bytes) + +**Example:** Find files in `/home/user/docs` where the filename appears in subdirectories with the same extension: + +```sql +select name, path from /home/user/docs as parent +where name in ( + select name from /home/user/docs/archive as child + where child.ext = parent.ext +) +``` + +The `as parent` and `as child` aliases allow the subquery to reference the outer query's `ext` column. + +#### `NOT IN` Operator + +The `NOT IN` operator checks if a value from the outer query does NOT match any value in the subquery. + +**Example:** Find files in `/current` that don't exist in `/backup` (by name): + +```sql +select name, path from /current +where name not in (select name from /backup) +``` + +**Example:** Find config files that exist in production but not in staging: + +```sql +select name, path from /production/config as prod +where name not in ( + select name from /staging/config where ext = 'cfg' +) +``` + +**Example:** Find files unique to a directory by both name and size: + +```sql +select name, size, path from /home/user/projects as proj +where name not in ( + select name from /home/user/archive as arch + where arch.size = proj.size +) +``` + +**Important Note:** `NOT IN` can produce unexpected results if the subquery returns any NULL/empty values. +When in doubt, use `NOT EXISTS` instead (see below). + +#### `EXISTS` Operator + +The `EXISTS` operator returns true if the subquery returns at least one row. +It's often more efficient than `IN` and handles NULL/empty values better. + +**Example:** Find directories that contain image files: + +```sql +select path from /home/user as parent +where is_dir and exists ( + select * from /home/user as child + where child.dir = parent.path and child.is_image +) +``` + +**Example:** Find files in `/data` that have a backup in `/backup` with the same name: + +```sql +select name, path, size from /data as data +where exists ( + select * from /backup as backup + where backup.name = data.name +) +``` + +**Example:** Find directories that have been modified recently (contain files modified in the last 7 days): + +```sql +select path from /home/user/projects gitignore as proj +where is_dir + and exists ( + select * from /home/user/projects as files + where is_file + and files.dir = proj.abspath + and files.modified >= 'last week' + ) +``` + +#### `NOT EXISTS` Operator + +The `NOT EXISTS` operator returns true if the subquery returns zero rows. This is the safest way to check for non-matching data. + +**Example:** Find files in `/production` that don't have a backup: + +```sql +select name, path from /production as prod +where not exists ( + select * from /backup as backup + where backup.name = prod.name +) +``` + +**Example:** Find files in `/cache` that don't have corresponding source files: + +```sql +select name, path from /cache as cache +where not exists ( + select * from /source as source + where source.name = cache.name + and source.size > 0 +) +``` + +**Example:** Find source files that have no corresponding test files: + +```sql +select name, path from /home/user/src as src +where ext = 'rs' and not exists ( + select * from /home/user/tests as tests + where tests.name like concat(src.name, '_test%') + and tests.ext = 'rs' +) +``` + +### Unix timestamps + +The `atime`, `mtime`, and `ctime` fields return raw Unix timestamps (seconds since epoch) as integers. +These are available only on Unix platforms and are useful when you need numeric comparison +or want to pass exact values to external tools. + + fselect name, mtime from /home/user/projects + fselect name, atime from /home/user where atime gt 1700000000 + fselect "name, format_time(mtime) from /home/user" + fselect "name, day(accessed), mtime from /home/user" + +Unlike `accessed`, `modified`, and `created` which return formatted date/time strings, +these fields return the raw integer value from the filesystem metadata. + +The companion `atime_nsec`, `mtime_nsec`, and `ctime_nsec` fields return the sub-second +nanosecond component of the respective timestamp, for cases where second-level precision +is not enough. + + fselect name, mtime, mtime_nsec from /home/user/projects + +### Git fields + +The git fields look up the repository containing each file (the nearest enclosing work tree) +and report per-file information. `git_status` returns one of `clean`, `modified`, `staged`, +`untracked`, `conflicted`, or `ignored`. The `git_last_commit_*` fields walk the repository +history to find the last commit that touched the file, like `git log -1 -- path`, so they are +relatively expensive on large histories. + + fselect name, git_status from /home/user/projects/repo where git_status = modified + fselect path from /home/user/projects/repo where is_git_tracked = false and is_gitignored = false + fselect name, git_last_commit_date, git_last_commit_author from src + fselect path, git_branch from /home/user/projects where is_git_repo = true depth 2 + +`is_git_repo` simply checks for a `.git` subdirectory (or file) and is available in every build. +All other git fields require the `git` feature (enabled by default). + +### Date and time specifiers + +When you specify inexact date and time with `=` or `!=` operator, **fselect** understands it as an interval. + + fselect path from /home/user where modified = 2017-05-01 + +`2017-05-01` means all day long from 00:00:00 to 23:59:59. + + fselect path from /home/user where modified = '2017-05-01 15' + +`2017-05-01 15` means one hour from 15:00:00 to 15:59:59. + + fselect path from /home/user where modified ne '2017-05-01 15:10' + +`2017-05-01 15:10` is a 1-minute interval from 15:10:00 to 15:10:59. + +Other operators assume the exact date and time, which could be specified in a freer way: + + fselect "path from /home/user where modified === 'apr 1'" + fselect "path from /home/user where modified gte 'last fri'" + fselect path from /home/user where modified gte '01/05' + +Or simply use relative offsets as days: + + fselect created, path from /home/user where created gte -2 + +[More about writing dates in plain English](https://github.com/stevedonovan/chrono-english) + +**fselect** uses *UK* locale by default, not American style dates, i.e. `08/02` means *February 8th* by default. + +To change this behavior, supply `--us-dates` option to the `fselect` command, or put `us_dates = true` into the configuration file. + +The safest way to specify dates is to use ISO 8601 format: `YYYY-MM-DD HH:MM:SS`. + +### Regular expressions + +[Rust flavor regular expressions](https://docs.rs/regex/latest/regex/index.html#syntax) are used. + +### MIME and file types + +For MIME guessing use field `mime`. It returns a simple string with a deduced MIME type, +which is not always accurate. + + fselect path, mime, is_binary, is_text from /home/user + +`is_binary` and `is_text` return `true` or `false` based on MIME type detected. +Once again, this should not be considered as a 100% accurate result, +or even possible at all to detect a correct file type. + +Other fields listed below **do NOT** use MIME detection. +Assumptions are being made based on file extension. + +The lists below could be edited with the configuration file. + +| Search field | Extensions | +|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `is_archive` | .7z, .bz2, .bzip2, .gz, .gzip, .lz, .rar, .tar, .xz, .zip | +| `is_audio` | .aac, .aiff, .amr, .flac, .gsm, .m4a, .m4b, .m4p, .mp3, .ogg, .wav, .wma | +| `is_book` | .azw3, .chm, .djv, .djvu, .epub, .fb2, .mobi, .pdf | +| `is_doc` | .accdb, .doc, .docm, .docx, .dot, .dotm, .dotx, .mdb, .odp, .ods, .odt, .pdf, .potm, .potx, .ppt, .pptm, .pptx, .rtf, .xlm, .xls, .xlsm, .xlsx, .xlt, .xltm, .xltx, .xps | +| `is_font` | .eot, .fon, .otc, .otf, .ttc, .ttf, .woff, .woff2 | +| `is_image` | .bmp, .exr, .gif, .heic, .jpeg, .jpg, .jxl, .png, .svg, .tga, .tiff, .webp | +| `is_source` | .asm, .awk, .bas, .c, .cc, .ceylon, .clj, .coffee, .cpp, .cs, .d, .dart, .elm, .erl, .go, .groovy, .h, .hh, .hpp, .java, .jl, .js, .jsp, .jsx, .kt, .kts, .lua, .nim, .pas, .php, .pl, .pm, .py, .qml, .rb, .rs, .scala, .sol, .swift, .tcl, .ts, .vala, .vb, .zig | +| `is_video` | .3gp, .avi, .flv, .m4p, .m4v, .mkv, .mov, .mp4, .mpeg, .mpg, .webm, .wmv | + + fselect is_archive, path from /home/user + fselect is_audio, is_video, path from /home/user/multimedia + fselect path from /home/user where is_doc != 1 + fselect path from /home/user where is_image = false + fselect path from /home/user where is_video != true + +### Audio support + +**fselect** reads audio metadata via [lofty](https://crates.io/crates/lofty), so it can search by +bitrate, sampling frequency, title, artist, album, genre, year, comment, track, and disc number across +many formats: MP3, FLAC, Ogg Vorbis, Opus, M4A/AAC/ALAC, WAV, AIFF, APE, WavPack, Musepack, and Speex. + +Duration is measured in seconds. + + fselect duration, bitrate, path from /home/user/music + fselect mp3_year, album, title from /home/user/music where artist like %Vampire% and bitrate gte 320 + fselect bitrate, freq, path from /home/user/music where genre = Rap or genre = HipHop + fselect path, title, artist from /home/user/music where ext in (flac, opus, m4a) + +### File hashes + +| Column | Meaning | +|------------------------|---------------------------| +| `sha1` | SHA-1 digest of a file | +| `sha2_256` or `sha256` | SHA2-256 digest of a file | +| `sha2_512` or `sha512` | SHA2-512 digest of a file | +| `sha3_512` or `sha3` | SHA3-512 digest of a file | + + fselect path, sha256, 256 from /home/user/archive limit 5 + fselect path from /home/user/Download where sha1 like cb23ef45% + +### Output formats + + ... into FORMAT + +| Format | Description | +|----------|---------------------------------------------------------------------------------| +| `tabs` | default, columns are separated with tabulation | +| `lines` | each column goes at a separate line | +| `list` | columns are separated with NULL symbol, similar to `-print0` argument of `find` | +| `csv` | comma-separated columns | +| `json` | array of resulting objects with requested columns | +| `html` | HTML document with table | + + fselect size, path from /home/user limit 5 into json + fselect size, path from /home/user limit 5 into csv + fselect size, path from /home/user limit 5 into html + fselect path from /home/user into list | xargs -0 grep foobar + +### Configuration file + +**fselect** tries to create a new configuration file if one doesn't exist. + +Usual location on Linux: + + /home/user_name/.config/fselect/config.toml + +On Windows: + + C:\Users\user_name\AppData\Roaming\jhspetersson\fselect\config.toml + +Fresh config is filled with defaults, feel free to update it. + +If no config on the standard paths is found, **fselect** checks its presence next to the executable. +You can also specify a config location with a runtime option, e.g.: + + fselect --config /home/user_name/fselect_custom.toml name, size from /home/user_name/Music where is_audio = 1 + +#### Check for updates + +**fselect** can be built with `update-notifications` feature, that enables automatic check for updates. +This check is disabled by default. To enable it, put + + check_for_updates = true + +into the config file. + +### Bash completion + +**fselect** comes with a bash completion script (`fselect-completion.bash`) that provides tab completion for: +- Directory paths after the `from` keyword +- Output formats after the `into` keyword +- Fields and functions in other contexts + +To enable bash completion for **fselect**, you need to install the completion script. The installation method varies depending on your Linux distribution: + +#### Ubuntu/Debian and Red Hat/Fedora/CentOS + +1. Copy the completion script to the bash completion directory: + +```bash +sudo cp fselect-completion.bash /etc/bash_completion.d/fselect +``` + +2. Make the script executable: + +```bash +sudo chmod +x /etc/bash_completion.d/fselect +``` + +3. Source the script or restart your shell: + +```bash +source /etc/bash_completion.d/fselect +``` + +#### Arch Linux + +1. Copy the completion script to the bash completion directory: + +```bash +sudo cp fselect-completion.bash /usr/share/bash-completion/completions/fselect +``` + +2. Make the script executable: + +```bash +sudo chmod +x /usr/share/bash-completion/completions/fselect +``` + +3. Source the script or restart your shell: + +```bash +source /usr/share/bash-completion/completions/fselect +``` + +#### Manual installation (any Linux distribution) + +If your distribution doesn't have a standard location for bash completion scripts, or if you don't have root access, you can install the script in your home directory: + +1. Create a directory for bash completion scripts if it doesn't exist: + +```bash +mkdir -p ~/.bash_completion.d +``` + +2. Copy the completion script to this directory: + +```bash +cp fselect-completion.bash ~/.bash_completion.d/fselect +``` + +3. Make the script executable: + +```bash +chmod +x ~/.bash_completion.d/fselect +``` + +4. Add the following line to your `~/.bashrc` file: + +```bash +source ~/.bash_completion.d/fselect +``` + +5. Source your `~/.bashrc` file or restart your shell: + +```bash +source ~/.bashrc +``` + +### Command-line arguments + +| Argument | Meaning | +|-------------------------------------------|----------------------------------------------| +| `--interactive` or `-i` or `/i` | Run in [interactive mode](#interactive-mode) | +| `--config` or `-c` or `/config` | Specify config file location | +| `--nocolor` or `--no-color` or `/nocolor` | Disable colors | +| `--no-errors` | Suppress error reporting | +| `--everything` | Use the *Everything* index as the file source (Windows, requires the `everything` build feature) | +| `--plocate` | Use the *plocate* index as the file source (Linux, requires the `plocate` build feature) | +| `--help` or `-h` or `/?` or `/h` | Show help and exit | + +### Index-backed search (Everything / plocate) + +**fselect** can optionally use an external file-name index as the source of candidate paths instead +of walking the filesystem. Because these indexes are prebuilt, enumerating a large directory tree is +typically much faster. Two backends are supported, each behind an opt-in build feature: + +- **Everything** (Windows) — the [voidtools *Everything*](https://www.voidtools.com/) engine, via its + client DLL. Enable with the `everything` feature and the `--everything` flag. +- **plocate** (Linux) — the [`plocate`](https://plocate.sesse.net/) `locate` replacement, invoked as a + subprocess. Enable with the `plocate` feature and the `--plocate` flag. + +``` +# Windows +cargo build --release --features everything +fselect --everything "name, size from C:\Users where size gt 100mb" + +# Linux +cargo build --release --features plocate +fselect --plocate "name, size from /home where size gt 100mb" +``` + +Both backends behave the same way: + +- They can also be enabled via the configuration file (`everything = true` / `plocate = true`). +- If the backend is unavailable — *Everything* not running / DLL missing, or the `plocate` binary or + its database missing — **fselect** transparently falls back to normal traversal. +- `mindepth`/`maxdepth` (and `depth`) constraints are applied to the index results. +- The `where`/`order by`/`select` logic, functions, and all fields work exactly as with traversal — + the index only supplies the candidate paths. +- Options that require reading the filesystem structure — searching `archives`, or applying + `.gitignore`/`.hgignore`/`.dockerignore` filters — automatically use normal traversal instead. +- Locations the index does not cover (for example, some network drives, or a stale `plocate` + database) will return no results in this mode. + +### Interactive mode + +In interactive mode, you can: +- execute queries directly without calling `fselect` every time +- use any characters without escaping them from the shell +- run multiple searches sequentially without restarting the tool +- edit and refine queries iteratively +- use command history (up/down arrows) to recall previous queries +- get current directory with `pwd` and change it with `cd` +- suppress error reporting with `errors off` +- exit with `quit`, `exit`, Ctrl+C or Ctrl+D + +### Environment variables + +**fselect** respects `NO_COLOR` [environment variable](https://no-color.org). + +### Exit values + +| Value | Meaning | +|-------|---------------------------------------------------------------------| +| 0 | everything OK | +| 1 | I/O error has occurred during any directory listing or file reading | +| 2 | error during parsing or evaluation of the search query | diff --git a/fselect-completion.bash b/fselect-completion.bash new file mode 100644 index 0000000..beb3f4a --- /dev/null +++ b/fselect-completion.bash @@ -0,0 +1,21 @@ +#!/bin/bash + +_fselect_complete() { + _init_completion || return + + for ((i=COMP_CWORD-1; i>=0; i--)); do + local word="${COMP_WORDS[i]}" + if [[ "${word,,}" == "from" ]]; then + _filedir -d + elif [[ "${word,,}" == "into" ]]; then + local output_formats=$(fselect --output-formats) + COMPREPLY=($(compgen -W "$output_formats" -- "${COMP_WORDS[COMP_CWORD]}")) + else + local fields=$(fselect --fields) + local functions=$(fselect --functions) + COMPREPLY=($(compgen -W "$fields $functions" -- "${COMP_WORDS[COMP_CWORD]}")) + fi + done +} + +complete -F _fselect_complete fselect diff --git a/resources/test/audio/silent-35s.mp3 b/resources/test/audio/silent-35s.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4a8894a771dd6f3fa9dcd96b8e3c04c5d543cc3b GIT binary patch literal 139691 zcmeIyafF@)0LSrXyBm>o5|L=r$i%tp&WV(zj;2j?OC&L6Nh}jH%A693q(2f#f8T-YN`~LCX^K;+#eV*sz@gBHiZgLpEt~U;W42I6QFd=;NcK(dTyEGVI)QYM9t{ z$)%S~U2)}AS6_4Op1s%ayJ32Ee&ME@Z{2_U;+=OLy8E8P_uc=%gGU~IT9pRv2yyYx8Hg9z4t%(@T1j_Kl${t&(D7GH9qs`A|D!E&NHUrp^o#tHX|! z(cC=@^UK39dGNMFi*w`KyLT%ue9QPe`<}VzJX`tp3!mL|o~4WL=R&)cqfO^|YMbuo z^fukk*=@R?bK7)3okux;3s;Lm>1`a)P(^Lp^1BV{D zX)1)dfkThnG!??!z@bNOnhIfV;LsyCO@%NwaOjbnrb3t-IP}O(Qz6U^9D3xYsSxG{ z4n1u3kK8mB!rZ{2M{b%5VQ%2iBR5TjFgI}M zk(;JMm>W3s$W2or%nclRu3kK8mB!rZ{2M{b%5VQ%2iBR5TjFgI}Mk(;JMm>W3s z$W2or%nclRu3kK8mB!rZ{2M{b%5VQ%2iBR5TjFgI}Mk(;JMm>W3s$W2or%nclR zu3kK8mB!rZ{2M{b%5VQ%2iBR5TjFgI}Mk(;JMm>W3s$W2or%nclRu3kK8mB z!rZ{2M{b%5VQ%2iBR5TjFgI}Mk(;JMm>W3s$W2or%nclRu3kK8mB!rZ{2M{b%5 zVQ%2iBR5TjFgI}Mk(;JMm>W3s$W2or%nclRu3kK8mB!rZ{2M{b%5VQ%2iBR5Tj zFgI}Mk(;JMm>W3s$W2or%nclR-1)VsGv_8& bhaD@!Fzg4lMDgE@&hzgax6AEsL8`Np literal 0 HcmV?d00001 diff --git a/resources/test/audio/silent.wav b/resources/test/audio/silent.wav new file mode 100644 index 0000000000000000000000000000000000000000..46af45e8f190da7e5dae886025153f120a7b8323 GIT binary patch literal 1440078 zcmeIuF$%&^5CqVT2aq&2R-PajLB!I+4}qXyg4O{^XBV{fUY^X(e+sKN)66zoyi1c( z`Yz&jxE{yHv??Q_$bEnEcoi{=GTNr=m-%c?>D+XwpXc2U&vCo2>RnZ@H*p`PA@Ua? zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs s0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7csf&U2n0AZ;RsQ>@~ literal 0 HcmV?d00001 diff --git a/resources/test/image/rect.svg b/resources/test/image/rect.svg new file mode 100644 index 0000000..7d82d86 --- /dev/null +++ b/resources/test/image/rect.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/test/image/rust-logo-blk.bmp b/resources/test/image/rust-logo-blk.bmp new file mode 100644 index 0000000000000000000000000000000000000000..43d5e2357e24c3b88de8548b6ec5f46d1ea29970 GIT binary patch literal 62262 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 literal 0 HcmV?d00001 diff --git a/resources/test/image/rust-logo-blk.jpeg b/resources/test/image/rust-logo-blk.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1613a2ca47234c73f145685d538cfe825ebfb711 GIT binary patch literal 4069 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 Izu|cDKQnTl5&!@I literal 0 HcmV?d00001 diff --git a/resources/test/image/rust-logo-blk.jpg b/resources/test/image/rust-logo-blk.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1613a2ca47234c73f145685d538cfe825ebfb711 GIT binary patch literal 4069 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 Izu|cDKQnTl5&!@I literal 0 HcmV?d00001 diff --git a/resources/test/image/rust-logo-blk.png b/resources/test/image/rust-logo-blk.png new file mode 100644 index 0000000000000000000000000000000000000000..e3d5cfc41ff65623fd953aa6fb11aec27e3e3806 GIT binary patch literal 7871 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 qpk~BX;aK(&`QO-`{r}dSzr?|#lSGfn#VFx`p> \ No newline at end of file diff --git a/resources/test/image/rust-logo-blk.tiff b/resources/test/image/rust-logo-blk.tiff new file mode 100644 index 0000000000000000000000000000000000000000..300a46beb0645e324558bf4698d5d18d11996af2 GIT binary patch literal 124704 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 wZ1hyeK7ARg%7X}Rn{t9S;th65J)cl`S@A#s0H? diff --git a/resources/test/video/rust-logo-blk.mkv b/resources/test/video/rust-logo-blk.mkv new file mode 100644 index 0000000000000000000000000000000000000000..2bec82b19430f68af7394e14967146f7d00f8a75 GIT binary patch literal 3687 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 literal 0 HcmV?d00001 diff --git a/resources/test/video/rust-logo-blk.mp4 b/resources/test/video/rust-logo-blk.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..8aa0b77a71e942b8fc5541aeac4f644a3a3d3217 GIT binary patch literal 3840 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 HJP!9?!twNT literal 0 HcmV?d00001 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..dd693bd --- /dev/null +++ b/src/config.rs @@ -0,0 +1,185 @@ +//! Handles configuration loading and saving + +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; + +const CONFIG_FILE: &str = "config.toml"; + +macro_rules! vec_of_strings { + ($($str:literal),*) => { + Some(vec![ + $(String::from($str)),* + ]) + } +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct Config { + pub no_color: Option, + pub gitignore: Option, + pub hgignore: Option, + pub dockerignore: Option, + pub is_zip_archive: Option>, + pub is_archive: Option>, + pub is_audio: Option>, + pub is_book: Option>, + pub is_doc: Option>, + pub is_font: Option>, + pub is_image: Option>, + pub is_source: Option>, + pub is_video: Option>, + pub default_file_size_format: Option, + pub us_dates: Option, + pub check_for_updates: Option, + #[serde(default)] + pub everything: Option, + #[serde(default)] + pub plocate: Option, + #[serde(skip_serializing, default = "get_false")] + pub debug: bool, + #[serde(skip)] + save: bool, +} + +fn get_false() -> bool { + false +} + +impl Config { + pub fn new() -> Result { + let mut config_file; + + if let Some(cf) = Self::get_current_dir_config() { + config_file = cf; + } else { + let config_dir = crate::util::app_dirs::get_project_dir(); + + if config_dir.is_none() { + return Ok(Config::default()); + } + + config_file = config_dir.unwrap(); + config_file.push(CONFIG_FILE); + + if !config_file.exists() { + return Ok(Config::default()); + } + } + + Config::from(config_file) + } + + pub fn from(config_file: PathBuf) -> Result { + if let Ok(mut file) = fs::File::open(config_file) { + let mut contents = String::new(); + if file.read_to_string(&mut contents).is_ok() { + toml::from_str(&contents).map_err(|err| err.to_string()) + } else { + Err("Could not read config file. Using default settings.".to_string()) + } + } else { + Err("Could not open config file. Using default settings.".to_string()) + } + } + + fn get_current_dir_config() -> Option { + if let Ok(mut pb) = std::env::current_exe() { + pb.pop(); + pb.push(CONFIG_FILE); + if pb.exists() { + return Some(pb); + } + } + + None + } + + pub fn save(&self) { + if !self.save { + return; + } + + let config_dir = crate::util::app_dirs::get_project_dir(); + + if config_dir.is_none() { + return; + } + + let mut config_file = config_dir.unwrap(); + let _ = fs::create_dir_all(&config_file); + config_file.push(CONFIG_FILE); + + if config_file.exists() { + return; + } + + let toml = toml::to_string_pretty(&self).unwrap(); + + if let Ok(mut file) = fs::File::create(&config_file) { + let _ = file.write_all(toml.as_bytes()); + } + } + + pub fn default() -> Config { + Config { + no_color: Some(false), + gitignore: Some(false), + hgignore: Some(false), + dockerignore: Some(false), + is_zip_archive: vec_of_strings![".zip", ".jar", ".war", ".ear"], + is_archive: vec_of_strings![ + ".7z", ".bz2", ".bzip2", ".gz", ".gzip", ".lz", ".rar", ".tar", ".xz", ".zip" + ], + is_audio: vec_of_strings![ + ".aac", ".aiff", ".amr", ".flac", ".gsm", ".m4a", ".m4b", ".m4p", ".mp3", ".ogg", + ".wav", ".wma" + ], + is_book: vec_of_strings![ + ".azw3", ".chm", ".djv", ".djvu", ".epub", ".fb2", ".mobi", ".pdf" + ], + is_doc: vec_of_strings![ + ".accdb", ".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".mdb", ".odp", + ".ods", ".odt", ".pdf", ".potm", ".potx", ".ppt", ".pptm", ".pptx", ".rtf", ".xlm", + ".xls", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".xps" + ], + is_font: vec_of_strings![ + ".eot", ".fon", ".otc", ".otf", ".ttc", ".ttf", ".woff", ".woff2" + ], + is_image: vec_of_strings![ + ".bmp", ".exr", ".gif", ".heic", ".jpeg", ".jpg", ".jxl", ".png", ".psb", ".psd", + ".svg", ".tga", ".tiff", ".webp" + ], + is_source: vec_of_strings![ + ".asm", ".awk", ".bas", ".c", ".cc", ".ceylon", ".clj", ".coffee", ".cpp", ".cs", ".d", + ".dart", ".elm", ".erl", ".go", ".gradle", ".groovy", ".h", ".hh", ".hpp", ".java", + ".jl", ".js", ".jsp", ".jsx", ".kt", ".kts", ".lua", ".nim", ".pas", ".php", ".pl", + ".pm", ".py", ".qml", ".rb", ".rs", ".scala", ".sol", ".swift", ".tcl", ".ts", ".tsx", + ".vala", ".vb", ".zig" + ], + is_video: vec_of_strings![ + ".3gp", ".avi", ".flv", ".m4p", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", + ".webm", ".wmv" + ], + default_file_size_format: Some(String::new()), + us_dates: Some(false), + check_for_updates: Some(false), + everything: Some(false), + plocate: Some(false), + debug: false, + save: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config() { + let config = Config::default(); + + assert!(config.is_source.unwrap().contains(&String::from(".rs"))); + } +} diff --git a/src/expr.rs b/src/expr.rs new file mode 100644 index 0000000..d0feffb --- /dev/null +++ b/src/expr.rs @@ -0,0 +1,1086 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fmt::Display; +use std::fmt::Formatter; +use std::hash::{DefaultHasher, Hash, Hasher}; +use crate::field::Field; +use crate::function::Function; +use crate::operators::ArithmeticOp; +use crate::operators::LogicalOp; +use crate::operators::Op; +use crate::query::Query; + +#[derive(Debug, Clone, PartialOrd, PartialEq, Eq, Hash, Serialize)] +pub struct Expr { + pub left: Option>, + pub arithmetic_op: Option, + pub logical_op: Option, + pub op: Option, + pub right: Option>, + pub minus: bool, + pub field: Option, + pub function: Option, + pub args: Option>, + pub val: Option, + pub subquery: Option>, + pub root_alias: Option, + pub alias: Option, + pub weight: i32, +} + +impl Expr { + pub fn new() -> Expr { + Expr { + left: None, + arithmetic_op: None, + logical_op: None, + op: None, + right: None, + minus: false, + field: None, + function: None, + args: None, + val: None, + subquery: None, + root_alias: None, + alias: None, + weight: 0, + } + } + + pub fn op(left: Expr, op: Op, right: Expr) -> Expr { + let left_weight = left.weight; + let right_weight = right.weight; + + Expr { + left: Some(Box::new(left)), + arithmetic_op: None, + logical_op: None, + op: Some(op), + right: Some(Box::new(right)), + minus: false, + field: None, + function: None, + args: None, + val: None, + subquery: None, + root_alias: None, + alias: None, + weight: left_weight + right_weight, + } + } + + pub fn logical_op(left: Expr, logical_op: LogicalOp, right: Expr) -> Expr { + let left_weight = left.weight; + let right_weight = right.weight; + + Expr { + left: Some(Box::new(left)), + arithmetic_op: None, + logical_op: Some(logical_op), + op: None, + right: Some(Box::new(right)), + minus: false, + field: None, + function: None, + args: None, + val: None, + subquery: None, + root_alias: None, + alias: None, + weight: left_weight + right_weight, + } + } + + pub fn arithmetic_op(left: Expr, arithmetic_op: ArithmeticOp, right: Expr) -> Expr { + let left_weight = left.weight; + let right_weight = right.weight; + + Expr { + left: Some(Box::new(left)), + arithmetic_op: Some(arithmetic_op), + logical_op: None, + op: None, + right: Some(Box::new(right)), + minus: false, + field: None, + function: None, + args: None, + val: None, + subquery: None, + root_alias: None, + alias: None, + weight: left_weight + right_weight, + } + } + + pub fn field(field: Field) -> Expr { + let weight = field.get_weight(); + + Expr { + left: None, + arithmetic_op: None, + logical_op: None, + op: None, + right: None, + minus: false, + field: Some(field), + function: None, + args: None, + val: None, + subquery: None, + root_alias: None, + alias: None, + weight, + } + } + + pub fn field_with_root_alias(field: Field, root_alias: Option) -> Expr { + let weight = field.get_weight(); + + Expr { + left: None, + arithmetic_op: None, + logical_op: None, + op: None, + right: None, + minus: false, + field: Some(field), + function: None, + args: None, + val: None, + subquery: None, + root_alias, + alias: None, + weight, + } + } + + pub fn function(function: Function) -> Expr { + let weight = function.get_weight(); + + Expr { + left: None, + arithmetic_op: None, + logical_op: None, + op: None, + right: None, + minus: false, + field: None, + function: Some(function), + args: Some(vec![]), + val: None, + subquery: None, + root_alias: None, + alias: None, + weight, + } + } + + pub fn function_left(function: Function, left: Option>) -> Expr { + let weight = function.get_weight(); + let left_weight = match left { + Some(ref expr) => expr.weight, + None => 0, + }; + + Expr { + left, + arithmetic_op: None, + logical_op: None, + op: None, + right: None, + minus: false, + field: None, + function: Some(function), + args: Some(vec![]), + val: None, + subquery: None, + root_alias: None, + alias: None, + weight: weight + left_weight, + } + } + + pub fn value(value: String) -> Expr { + Expr { + left: None, + arithmetic_op: None, + logical_op: None, + op: None, + right: None, + minus: false, + field: None, + function: None, + args: None, + val: Some(value), + subquery: None, + root_alias: None, + alias: None, + weight: 0, + } + } + + pub fn subquery(subquery: Query) -> Expr { + let weight = match subquery.expr { + Some(ref expr) => expr.weight, + None => 0, + }; + + Expr { + left: None, + arithmetic_op: None, + logical_op: None, + op: None, + right: None, + minus: false, + field: None, + function: None, + args: None, + val: None, + subquery: Some(Box::new(subquery)), + root_alias: None, + alias: None, + weight, + } + } + + pub fn add_left(&mut self, left: Expr) { + let old_weight = self.left.as_ref().map_or(0, |l| l.weight); + let left_weight = left.weight; + self.left = Some(Box::new(left)); + self.weight = self.weight - old_weight + left_weight; + } + + pub fn set_args(&mut self, args: Vec) { + let old_weight: i32 = self.args.as_ref().map_or(0, |a| a.iter().map(|e| e.weight).sum()); + let mut args_weight = 0; + for arg in &args { + args_weight += arg.weight; + } + self.args = Some(args); + self.weight = self.weight - old_weight + args_weight; + } + + pub fn has_aggregate_function(&self) -> bool { + if let Some(ref left) = self.left + && left.has_aggregate_function() { + return true; + } + + if let Some(ref right) = self.right + && right.has_aggregate_function() { + return true; + } + + if let Some(ref function) = self.function + && function.is_aggregate_function() { + return true; + } + + if let Some(ref args) = self.args { + for arg in args { + if arg.has_aggregate_function() { + return true; + } + } + } + + false + } + + pub fn get_required_fields(&self) -> HashSet { + let mut result = HashSet::new(); + + if let Some(ref left) = self.left { + result.extend(left.get_required_fields()); + } + + if let Some(ref right) = self.right { + result.extend(right.get_required_fields()); + } + + if let Some(field) = self.field { + result.insert(field); + } + + if let Some(ref args) = self.args { + for arg in args { + result.extend(arg.get_required_fields()); + } + } + + result + } + + pub fn get_fields_required_in_subqueries(&self, alias: &str, parent_subquery: bool) -> HashMap { + let mut result = HashMap::new(); + + if let Some(ref subquery) = self.subquery { + // A correlated subquery can reference an outer field from any of its + // clauses, not just WHERE: ORDER BY and GROUP BY exprs are evaluated + // per inner row too, so their outer-alias references must also be + // propagated via record_context. + if let Some(ref expr) = subquery.expr { + result.extend(expr.get_fields_required_in_subqueries(alias, true)); + } + for ordering_expr in &subquery.ordering_fields { + result.extend(ordering_expr.get_fields_required_in_subqueries(alias, true)); + } + for grouping_expr in &subquery.grouping_fields { + result.extend(grouping_expr.get_fields_required_in_subqueries(alias, true)); + } + } + + if let Some(ref left) = self.left { + result.extend(left.get_fields_required_in_subqueries(alias, parent_subquery)); + } + + if let Some(ref right) = self.right { + result.extend(right.get_fields_required_in_subqueries(alias, parent_subquery)); + } + + if let Some(ref args) = self.args { + for arg in args { + result.extend(arg.get_fields_required_in_subqueries(alias, parent_subquery)); + } + } + + if let Some(ref expr_alias) = self.root_alias + && expr_alias == alias + && let Some(field) = self.field + && parent_subquery { + let alias = if let Some(ref alias) = self.alias { alias } else { &field.to_string() }; + result.insert(field, alias.clone()); + } + + result + } + + pub fn contains_numeric(&self) -> bool { + Self::contains_numeric_field(self) + } + + fn contains_numeric_field(expr: &Expr) -> bool { + let field = match expr.field { + Some(ref field) => field.is_numeric_field(), + None => false, + }; + + if field { + return true; + } + + let function = match expr.function { + Some(ref function) => function.is_numeric_function(), + None => false, + }; + + if function { + return true; + } + + if let Some(ref left) = expr.left + && Self::contains_numeric_field(left) { + return true; + } + + if let Some(ref right) = expr.right + && Self::contains_numeric_field(right) { + return true; + } + + if let Some(ref args) = expr.args { + for arg in args { + if Self::contains_numeric_field(arg) { + return true; + } + } + } + + false + } + + pub fn contains_datetime(&self) -> bool { + Self::contains_datetime_field(self) + } + + fn contains_datetime_field(expr: &Expr) -> bool { + let field = match expr.field { + Some(ref field) => field.is_datetime_field(), + None => false, + }; + + if field { + return true; + } + + if let Some(ref left) = expr.left + && Self::contains_datetime_field(left) { + return true; + } + + if let Some(ref right) = expr.right + && Self::contains_datetime_field(right) { + return true; + } + + if let Some(ref args) = expr.args { + for arg in args { + if Self::contains_datetime_field(arg) { + return true; + } + } + } + + false + } + + /// Walks the expression tree and validates string literals that are + /// compared against datetime fields. Returns `Err` with the first + /// unparseable literal so the caller can fail the query once, up front, + /// rather than emitting one parse error per file scanned. + /// + /// Only literals adjacent to a datetime *field* are checked — comparisons + /// against datetime-returning function results are not validated here + /// because that would require tracking function return types. Per-file + /// degradation in `conforms()` still catches those silently. + pub fn validate_datetime_literals(&self) -> Result<(), String> { + Self::validate_dt_node(self) + } + + fn validate_dt_node(expr: &Expr) -> Result<(), String> { + if expr.op.is_some() { + let left_is_dt = expr + .left + .as_deref() + .and_then(|l| l.field.as_ref()) + .is_some_and(Field::is_datetime_field); + let right_is_dt = expr + .right + .as_deref() + .and_then(|r| r.field.as_ref()) + .is_some_and(Field::is_datetime_field); + + if left_is_dt + && let Some(ref right) = expr.right { + Self::check_dt_value_side(right)?; + } + if right_is_dt + && let Some(ref left) = expr.left { + Self::check_dt_value_side(left)?; + } + } + + if let Some(ref left) = expr.left { + Self::validate_dt_node(left)?; + } + if let Some(ref right) = expr.right { + Self::validate_dt_node(right)?; + } + if let Some(ref args) = expr.args { + for arg in args { + Self::validate_dt_node(arg)?; + } + } + if let Some(ref subquery) = expr.subquery + && let Some(ref inner) = subquery.expr { + Self::validate_dt_node(inner)?; + } + + Ok(()) + } + + /// Validate the non-field side of a comparison against a datetime field. + /// Recurses into IN/NotIn arg lists; tries to parse plain literal `val`s. + fn check_dt_value_side(expr: &Expr) -> Result<(), String> { + if let Some(ref args) = expr.args { + for arg in args { + Self::check_dt_value_side(arg)?; + } + } + if let Some(ref val) = expr.val + && expr.field.is_none() && expr.function.is_none() && expr.subquery.is_none() + && crate::util::parse_datetime(val).is_err() { + return Err(format!("Can't parse datetime: {}", val)); + } + Ok(()) + } + + pub fn contains_colorized(&self) -> bool { + Self::contains_colorized_field(self) + } + + fn contains_colorized_field(expr: &Expr) -> bool { + if expr.function.is_some() { + return false; + } + + let field = match expr.field { + Some(ref field) => field.is_colorized_field(), + None => false, + }; + + if field { + return true; + } + + if let Some(ref left) = expr.left + && Self::contains_colorized_field(left) { + return true; + } + + if let Some(ref right) = expr.right + && Self::contains_colorized_field(right) { + return true; + } + + false + } +} + +impl Display for Expr { + fn fmt(&self, fmt: &mut Formatter) -> fmt::Result { + use std::fmt::Write; + + if let Some(ref subquery) = self.subquery { + if let Some(ref alias) = self.alias { + if let Some(ref root_alias) = self.root_alias { + fmt.write_str(root_alias)?; + fmt.write_char('.')?; + } + fmt.write_str(alias)?; + } else { + let subquery_str = format!("{:?}", subquery); + let mut s = DefaultHasher::new(); + subquery_str.hash(&mut s); + let hash = s.finish(); + fmt.write_str(("subquery_".to_string() + hash.to_string().as_str()).as_str())?; + } + } + + if self.minus { + fmt.write_char('-')?; + } + + // A negated composite must keep its grouping: -(1 Add 2) and + // (-1) Add 2 would otherwise render identically and share a cache key. + let negated_composite = self.minus + && (self.arithmetic_op.is_some() || self.logical_op.is_some() || self.op.is_some()); + if negated_composite { + fmt.write_char('(')?; + } + + if let Some(ref function) = self.function { + if let Some(ref alias) = self.alias { + fmt.write_str(alias)?; + } else { + fmt.write_str(&function.to_string())?; + fmt.write_char('(')?; + if let Some(ref left) = self.left { + fmt.write_str(&left.to_string())?; + } + if let Some(ref args) = self.args { + for (i, arg) in args.iter().enumerate() { + if self.left.is_some() || i > 0 { + fmt.write_str(", ")?; + } + fmt.write_str(&arg.to_string())?; + } + } + fmt.write_char(')')?; + } + } else if let Some(ref left) = self.left { + write_operand(fmt, left)?; + } + + if let Some(ref op) = self.arithmetic_op { + fmt.write_str(&format!(" {:?} ", op))?; + } + + if let Some(ref op) = self.logical_op { + fmt.write_str(&format!(" {:?} ", op))?; + } + + if let Some(ref op) = self.op { + fmt.write_str(&format!(" {:?} ", op))?; + } + + if let Some(ref field) = self.field { + if let Some(ref root_alias) = self.root_alias { + fmt.write_str(root_alias)?; + fmt.write_char('.')?; + } + if let Some(ref alias) = self.alias { + fmt.write_str(alias)?; + } else { + fmt.write_str(&field.to_string())?; + } + } + + if let Some(ref val) = self.val { + fmt.write_str(val)?; + } + + if let Some(ref right) = self.right { + write_operand(fmt, right)?; + } + + if negated_composite { + fmt.write_char(')')?; + } + + Ok(()) + } +} + +/// Write a child operand, parenthesized when it is itself a composite +/// expression. Without this, `(1 + 2) * 3` and `1 + (2 * 3)` render to the +/// same string, and the rendered form is used as the per-file evaluation +/// cache key — colliding columns would silently reuse each other's values. +fn write_operand(fmt: &mut Formatter, operand: &Expr) -> fmt::Result { + use std::fmt::Write; + + let composite = operand.arithmetic_op.is_some() + || operand.logical_op.is_some() + || operand.op.is_some(); + + if composite { + fmt.write_char('(')?; + } + Display::fmt(operand, fmt)?; + if composite { + fmt.write_char(')')?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::field::Field; + use crate::function::Function; + use crate::lexer::Lexer; + use crate::parser::Parser; + + #[test] + fn display_distinguishes_operand_grouping() { + // The rendered form is the per-file evaluation cache key: without + // parentheses, (1+2)*3 and 1+(2*3) collide and reuse each other's + // cached values. + let query = "select (1+2)*3, 1+(2*3) from /test"; + let mut lexer = Lexer::new(vec![query.to_string()]); + let mut parser = Parser::new(&mut lexer); + let query = parser.parse(false).unwrap(); + + assert_ne!(query.fields[0].to_string(), query.fields[1].to_string()); + } + + #[test] + fn display_distinguishes_negated_composite() { + // -(1+2) is -3 while -1+2 is 1; without parentheses around the negated + // composite both would render identically and share a cache key. + let query = "select -(1+2), -1+2 from /test"; + let mut lexer = Lexer::new(vec![query.to_string()]); + let mut parser = Parser::new(&mut lexer); + let query = parser.parse(false).unwrap(); + + assert_ne!(query.fields[0].to_string(), query.fields[1].to_string()); + } + + #[test] + fn test_weight() { + let expr = Expr::field(Field::Name); + assert_eq!(expr.weight, 0); + + let expr = Expr::field(Field::Accessed); + assert_eq!(expr.weight, 1); + + let expr = Expr::function(Function::Concat); + assert_eq!(expr.weight, 0); + + let expr = Expr::function(Function::Contains); + assert_eq!(expr.weight, 1024); + + let expr = Expr::function_left(Function::Contains, Some(Box::new(Expr::value("foo".to_string())))); + assert_eq!(expr.weight, 1024); + + let expr = Expr::logical_op( + Expr::op( + Expr::field(Field::Size), + Op::Gt, + Expr::value(String::from("456")), + ), + LogicalOp::Or, + Expr::op( + Expr::field(Field::FormattedSize), + Op::Lte, + Expr::value(String::from("758")), + ), + ); + assert_eq!(expr.weight, 2); + + let expr = Expr::logical_op( + Expr::logical_op( + Expr::op( + Expr::field(Field::Name), + Op::Ne, + Expr::value(String::from("123")), + ), + LogicalOp::And, + Expr::logical_op( + Expr::op( + Expr::field(Field::Size), + Op::Gt, + Expr::value(String::from("456")), + ), + LogicalOp::Or, + Expr::op( + Expr::field(Field::FormattedSize), + Op::Lte, + Expr::value(String::from("758")), + ), + ), + ), + LogicalOp::Or, + Expr::op( + Expr::field(Field::Name), + Op::Eq, + Expr::value(String::from("xxx")), + ), + ); + assert_eq!(expr.weight, 2); + } + + fn parse_where_expr(sql: &str) -> Expr { + let mut lexer = Lexer::new(vec![sql.to_string()]); + let mut parser = Parser::new(&mut lexer); + let query = parser.parse(false).expect("parse should succeed"); + query.expr.expect("query should have where expr") + } + + #[test] + fn no_subqueries_returns_fields_from_top_level_for_alias() { + let expr = parse_where_expr("select t1.name from /t1 as t1 where t1.size > 10"); + let set = expr.get_fields_required_in_subqueries("t1", false); + assert!(set.is_empty()); + } + + #[test] + fn uncorrelated_exists_returns_empty_for_outer_alias() { + let expr = parse_where_expr( + "select t1.name from /t1 as t1 where exists(select t2.name from /t2 as t2 where t2.size > 0)" + ); + let set = expr.get_fields_required_in_subqueries("t1", false); + assert!(set.is_empty(), "Expected no required fields for t1 in uncorrelated subquery"); + } + + #[test] + fn correlated_exists_collects_parent_fields() { + let expr = parse_where_expr( + "select t1.name from /t1 as t1 where exists(select t2.name from /t2 as t2 where t2.name = t1.name and t2.size > t1.size)" + ); + let map = expr.get_fields_required_in_subqueries("t1", false); + assert_eq!(map, HashMap::from([(Field::Name, String::from("Name")), (Field::Size, String::from("Size"))])); + let set = expr.right.unwrap().subquery.unwrap().expr.unwrap().get_fields_required_in_subqueries("t2", false); + assert!(set.is_empty(), "Expected no required fields for t2 in correlated subquery"); + } + + #[test] + fn correlated_not_exists_collects_parent_fields() { + let expr = parse_where_expr( + "select t1.name from /t1 as t1 where not exists(select t2.name from /t2 as t2 where t2.name = t1.name and t2.size > t1.size)" + ); + let map = expr.get_fields_required_in_subqueries("t1", false); + assert_eq!(map, HashMap::from([(Field::Name, String::from("Name")), (Field::Size, String::from("Size"))])); + let map = expr.right.unwrap().subquery.unwrap().expr.unwrap().get_fields_required_in_subqueries("t2", false); + assert!(map.is_empty(), "Expected no required fields for t2 in correlated subquery"); + } + + #[test] + fn contains_numeric_right_side_of_arithmetic() { + let expr = Expr::arithmetic_op( + Expr::value("1".to_string()), + ArithmeticOp::Add, + Expr::field(Field::Size), + ); + assert!(expr.contains_numeric()); + } + + #[test] + fn contains_numeric_right_side_of_op() { + let expr = Expr::op( + Expr::value("100".to_string()), + Op::Gt, + Expr::field(Field::Size), + ); + assert!(expr.contains_numeric()); + } + + #[test] + fn contains_datetime_right_side_of_op() { + let expr = Expr::op( + Expr::value("2024-01-01".to_string()), + Op::Lt, + Expr::field(Field::Accessed), + ); + assert!(expr.contains_datetime()); + } + + #[test] + fn contains_colorized_right_side_of_op() { + let expr = Expr::op( + Expr::value("foo".to_string()), + Op::Eq, + Expr::field(Field::Name), + ); + assert!(expr.contains_colorized()); + } + + #[test] + fn contains_numeric_in_function_args() { + let mut expr = Expr::function(Function::Concat); + expr.set_args(vec![Expr::field(Field::Size)]); + assert!(expr.contains_numeric()); + } + + #[test] + fn contains_datetime_in_function_args() { + let mut expr = Expr::function(Function::Concat); + expr.set_args(vec![Expr::field(Field::Accessed)]); + assert!(expr.contains_datetime()); + } + + #[test] + fn display_arithmetic_op_shows_operator() { + let expr = Expr::arithmetic_op( + Expr::value("1".to_string()), + ArithmeticOp::Add, + Expr::value("2".to_string()), + ); + let displayed = format!("{}", expr); + assert!(displayed.contains("Add"), + "Expected 'Add' in display, got: {}", displayed); + } + + #[test] + fn display_logical_op_shows_operator() { + let expr = Expr::logical_op( + Expr::op( + Expr::field(Field::Name), + Op::Eq, + Expr::value("foo".to_string()), + ), + LogicalOp::And, + Expr::op( + Expr::field(Field::Size), + Op::Gt, + Expr::value("0".to_string()), + ), + ); + let displayed = format!("{}", expr); + assert!(displayed.contains("And"), + "Expected 'And' in display, got: {}", displayed); + } + + #[test] + fn display_comparison_op_shows_operator() { + let expr = Expr::op( + Expr::field(Field::Size), + Op::Gt, + Expr::value("100".to_string()), + ); + let displayed = format!("{}", expr); + assert!(displayed.contains("Gt"), + "Expected 'Gt' in display, got: {}", displayed); + } + + #[test] + fn display_function_shows_args() { + let mut expr = Expr::function(Function::Round); + expr.add_left(Expr::value("3.14".to_string())); + expr.set_args(vec![Expr::value("2".to_string())]); + let displayed = format!("{}", expr); + assert!(displayed.contains("2"), + "Expected args in display, got: {}", displayed); + } + + #[test] + fn add_left_replaces_old_weight() { + let heavy = Expr::field(Field::Accessed); + let heavy_weight = heavy.weight; + let light = Expr::value("x".to_string()); + let light_weight = light.weight; + + let mut expr = Expr::function_left(Function::Lower, Some(Box::new(heavy))); + let base_weight = expr.weight; + assert_eq!(base_weight, Function::Lower.get_weight() + heavy_weight); + + expr.add_left(light); + assert_eq!(expr.weight, Function::Lower.get_weight() + light_weight, + "weight should reflect only the new left, got: {}", expr.weight); + } + + #[test] + fn set_args_replaces_old_weight() { + let mut expr = Expr::function(Function::Concat); + let heavy_args = vec![Expr::field(Field::Accessed)]; + let heavy_weight: i32 = heavy_args.iter().map(|a| a.weight).sum(); + expr.set_args(heavy_args); + let weight_after_first = expr.weight; + assert_eq!(weight_after_first, Function::Concat.get_weight() + heavy_weight); + + let light_args = vec![Expr::value("x".to_string())]; + let light_weight: i32 = light_args.iter().map(|a| a.weight).sum(); + expr.set_args(light_args); + assert_eq!(expr.weight, Function::Concat.get_weight() + light_weight, + "weight should reflect only the new args, got: {}", expr.weight); + } + + #[test] + fn get_fields_required_in_subqueries_checks_args() { + let mut func_expr = Expr::function(Function::ConcatWs); + func_expr.add_left(Expr::value(",".to_string())); + func_expr.set_args(vec![ + Expr::field_with_root_alias(Field::Name, Some("t1".to_string())), + Expr::field_with_root_alias(Field::Name, Some("t2".to_string())), + ]); + let map = func_expr.get_fields_required_in_subqueries("t1", true); + assert_eq!(map.len(), 1, "Expected t1.name in args to be found, got: {:?}", map); + assert!(map.contains_key(&Field::Name)); + } + + #[test] + fn validate_datetime_literals_accepts_valid_dates() { + let expr = Expr::op( + Expr::field(Field::Modified), + Op::Eq, + Expr::value(String::from("2026-01-01")), + ); + assert!(expr.validate_datetime_literals().is_ok()); + } + + #[test] + fn validate_datetime_literals_accepts_today_keyword() { + let expr = Expr::op( + Expr::field(Field::Modified), + Op::Gt, + Expr::value(String::from("today")), + ); + assert!(expr.validate_datetime_literals().is_ok()); + } + + #[test] + fn validate_datetime_literals_rejects_garbage() { + let expr = Expr::op( + Expr::field(Field::Modified), + Op::Eq, + Expr::value(String::from("not-a-date")), + ); + let err = expr.validate_datetime_literals().unwrap_err(); + assert!(err.contains("not-a-date"), "expected error to mention bad literal, got: {}", err); + } + + #[test] + fn validate_datetime_literals_skips_non_datetime_fields() { + // `name = 'not-a-date'` is a perfectly valid string comparison — + // validation must not flag literals when the field isn't datetime. + let expr = Expr::op( + Expr::field(Field::Name), + Op::Eq, + Expr::value(String::from("not-a-date")), + ); + assert!(expr.validate_datetime_literals().is_ok()); + } + + #[test] + fn validate_datetime_literals_recurses_into_logical_op() { + // `size > 0 AND modified = 'oops'` — bad literal is in a nested arm. + let expr = Expr::logical_op( + Expr::op( + Expr::field(Field::Size), + Op::Gt, + Expr::value(String::from("0")), + ), + LogicalOp::And, + Expr::op( + Expr::field(Field::Modified), + Op::Eq, + Expr::value(String::from("oops")), + ), + ); + let err = expr.validate_datetime_literals().unwrap_err(); + assert!(err.contains("oops")); + } + + #[test] + fn validate_datetime_literals_recurses_into_in_args() { + // `modified IN ('2026-01-01', 'bad')` — IN args are validated too. + let mut right = Expr::value(String::from("")); + right.args = Some(vec![ + Expr::value(String::from("2026-01-01")), + Expr::value(String::from("bad")), + ]); + let expr = Expr::op(Expr::field(Field::Modified), Op::In, right); + let err = expr.validate_datetime_literals().unwrap_err(); + assert!(err.contains("bad")); + } + + #[test] + fn validate_datetime_literals_ignores_field_on_value_side() { + // `modified = created` — both sides are fields, no literal to validate. + let expr = Expr::op( + Expr::field(Field::Modified), + Op::Eq, + Expr::field(Field::Created), + ); + assert!(expr.validate_datetime_literals().is_ok()); + } + + #[test] + fn deeply_nested_subquery_can_reference_outer_alias() { + let expr = parse_where_expr( + "select t1.name from /t1 as t1 where exists(select t2.name from /t2 as t2 where t2.name in (select t3.name from /t3 as t3 where t3.modified = t1.modified) and t2.size > t1.size)" + ); + let map = expr.get_fields_required_in_subqueries("t1", false); + assert_eq!(map, HashMap::from([(Field::Modified, String::from("Modified")), (Field::Size, String::from("Size"))])); + let map = expr.clone().right.unwrap().subquery.unwrap().expr.unwrap().get_fields_required_in_subqueries("t2", false); + assert!(map.is_empty(), "Expected no required fields for t2 in correlated subquery"); + let map = expr.clone().right.unwrap().subquery.unwrap().expr.unwrap().left.unwrap().right.unwrap().subquery.unwrap().expr.unwrap().get_fields_required_in_subqueries("t3", false); + assert!(map.is_empty(), "Expected no required fields for t3 in correlated subquery"); + let map = expr.right.unwrap().subquery.unwrap().expr.unwrap().left.unwrap().right.unwrap().subquery.unwrap().expr.unwrap().get_fields_required_in_subqueries("t1", false); + assert!(map.is_empty(), "Expected no required fields for t1 in correlated subquery"); + } + + #[test] + fn correlated_order_by_in_subquery_collects_parent_fields() { + // The subquery's ONLY reference to the outer alias `t1` lives in its + // ORDER BY clause. The dependency walk must still surface `t1.size` so + // the outer row's value is propagated to the subquery via + // record_context; otherwise the ordering reads an empty value. + let expr = parse_where_expr( + "select t1.name from /t1 as t1 where t1.size = (select t2.size from /t2 as t2 order by abs(t2.size - t1.size) limit 1)" + ); + let map = expr.get_fields_required_in_subqueries("t1", false); + assert_eq!( + map, + HashMap::from([(Field::Size, String::from("Size"))]), + "ORDER BY correlation on t1.size must be collected, got: {:?}", map + ); + } + + #[test] + fn correlated_group_by_in_subquery_collects_parent_fields() { + // Same as above, but the outer-alias reference lives in GROUP BY. + let expr = parse_where_expr( + "select t1.name from /t1 as t1 where t1.size = (select max(t2.size) from /t2 as t2 group by abs(t2.size - t1.size) limit 1)" + ); + let map = expr.get_fields_required_in_subqueries("t1", false); + assert_eq!( + map, + HashMap::from([(Field::Size, String::from("Size"))]), + "GROUP BY correlation on t1.size must be collected, got: {:?}", map + ); + } +} \ No newline at end of file diff --git a/src/field/content_handlers.rs b/src/field/content_handlers.rs new file mode 100644 index 0000000..931cd1f --- /dev/null +++ b/src/field/content_handlers.rs @@ -0,0 +1,366 @@ +use crate::field::Field; +use crate::field::context::FieldContext; +use crate::util::*; +use crate::util::error::SearchError; + +pub fn handle_line_count(ctx: &mut FieldContext) -> Result { + ctx.fms.update_line_count(ctx.entry); + if let Some(line_count) = ctx.fms.get_line_count() { + return Ok(Variant::from_int(line_count as i64)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_word_count(ctx: &mut FieldContext) -> Result { + ctx.fms.update_content_stats(ctx.entry); + if let Some(stats) = ctx.fms.get_content_stats() + && stats.is_text { + return Ok(Variant::from_int(stats.word_count as i64)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_char_count(ctx: &mut FieldContext) -> Result { + ctx.fms.update_content_stats(ctx.entry); + if let Some(stats) = ctx.fms.get_content_stats() + && stats.is_text { + return Ok(Variant::from_int(stats.char_count as i64)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_encoding(ctx: &mut FieldContext) -> Result { + ctx.fms.update_content_stats(ctx.entry); + if let Some(stats) = ctx.fms.get_content_stats() + && stats.is_text { + return Ok(Variant::from_string(&stats.encoding)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_has_bom(ctx: &mut FieldContext) -> Result { + if let Some(stats) = ctx.fms.get_content_stats() { + return Ok(Variant::from_bool(stats.has_bom)); + } + Ok(Variant::from_bool(has_bom(ctx.entry))) +} + +pub fn handle_line_ending(ctx: &mut FieldContext) -> Result { + ctx.fms.update_content_stats(ctx.entry); + if let Some(stats) = ctx.fms.get_content_stats() + && stats.is_text { + return Ok(Variant::from_string(&stats.line_ending)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_mime(ctx: &mut FieldContext) -> Result { + ctx.fms.update_mime_type(ctx.entry); + if let Some(mime) = ctx.fms.get_mime_type() { + return Ok(Variant::from_string(&String::from(mime))); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_is_binary_or_text(ctx: &mut FieldContext, field: &Field) -> Result { + ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks); + if let Some(meta) = ctx.fms.get_file_metadata() + && meta.is_dir() { + return Ok(Variant::from_bool(false)); + } + + ctx.fms.update_mime_type(ctx.entry); + if let Some(mime) = ctx.fms.get_mime_type() { + let is_text = is_text_mime(mime); + let result = matches!(field, Field::IsText) == is_text; + return Ok(Variant::from_bool(result)); + } + + Ok(Variant::from_bool(false)) +} + +pub fn handle_is_type(ctx: &mut FieldContext, field: &Field) -> Result { + let os_name = ctx.entry.file_name(); + let name = match ctx.file_info { + Some(fi) => fi.name.as_str(), + None => &os_name.to_string_lossy(), + }; + let result = match field { + Field::IsArchive => check_extension(name, &ctx.config.is_archive, &ctx.default_config.is_archive), + Field::IsAudio => check_extension(name, &ctx.config.is_audio, &ctx.default_config.is_audio), + Field::IsBook => check_extension(name, &ctx.config.is_book, &ctx.default_config.is_book), + Field::IsDoc => check_extension(name, &ctx.config.is_doc, &ctx.default_config.is_doc), + Field::IsFont => check_extension(name, &ctx.config.is_font, &ctx.default_config.is_font), + Field::IsImage => check_extension(name, &ctx.config.is_image, &ctx.default_config.is_image), + Field::IsSource => check_extension(name, &ctx.config.is_source, &ctx.default_config.is_source), + Field::IsVideo => check_extension(name, &ctx.config.is_video, &ctx.default_config.is_video), + _ => return Err(SearchError::fatal(format!("Unexpected field in handle_is_type: {:?}", field))), + }; + Ok(Variant::from_bool(result)) +} + +fn check_extension( + file_name: &str, + config_ext: &Option>, + default_ext: &Option>, +) -> bool { + match config_ext.as_ref().or(default_ext.as_ref()) { + Some(extensions) => has_extension(file_name, extensions), + None => false, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use crate::config::Config; + + use super::*; + + fn test_field(entry: &fs::DirEntry, root_path: &Path, field: &Field) -> Variant { + let config = Config::default(); + let default_config = Config::default(); + let mut fms = crate::field::context::FileMetadataState::new(); + #[cfg(feature = "git")] + let mut git_cache = crate::util::git::GitCache::new(); + #[cfg(all(unix, feature = "users"))] + let user_cache = uzers::UsersCache::new(); + let none_file_info = None; + let mut ctx = FieldContext { + entry, + file_info: &none_file_info, + root_path, + fms: &mut fms, + #[cfg(feature = "git")] + git_cache: &mut git_cache, + follow_symlinks: true, + config: &config, + default_config: &default_config, + #[cfg(all(unix, feature = "users"))] + user_cache: &user_cache, + }; + crate::field::dispatch::get_field_value(&mut ctx, field).unwrap() + } + + fn entry_for(dir: &Path, name: &str) -> fs::DirEntry { + fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok()) + .find(|e| e.file_name() == name) + .unwrap() + } + + #[test] + fn test_content_fields_on_text_file() { + let tmp = std::env::temp_dir().join("fselect_test_content_fields_text_h"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("a.txt"), "hello world\nsecond line\n").unwrap(); + + let entry = entry_for(&tmp, "a.txt"); + + assert_eq!(test_field(&entry, &tmp, &Field::WordCount).to_string(), "4"); + assert_eq!(test_field(&entry, &tmp, &Field::CharCount).to_string(), "24"); + assert_eq!(test_field(&entry, &tmp, &Field::Encoding).to_string(), "ASCII"); + assert_eq!(test_field(&entry, &tmp, &Field::HasBom).to_string(), "false"); + assert_eq!(test_field(&entry, &tmp, &Field::LineEnding).to_string(), "LF"); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_content_fields_on_binary_file() { + let tmp = std::env::temp_dir().join("fselect_test_content_fields_binary_h"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("a.bin"), [0x00u8, 0x01, 0x02, b'x']).unwrap(); + + let entry = entry_for(&tmp, "a.bin"); + + // Text-only fields surface an empty value for binary content. + assert_eq!(test_field(&entry, &tmp, &Field::WordCount).to_string(), ""); + assert_eq!(test_field(&entry, &tmp, &Field::CharCount).to_string(), ""); + assert_eq!(test_field(&entry, &tmp, &Field::Encoding).to_string(), ""); + assert_eq!(test_field(&entry, &tmp, &Field::LineEnding).to_string(), ""); + // has_bom is still a definite boolean. + assert_eq!(test_field(&entry, &tmp, &Field::HasBom).to_string(), "false"); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_has_bom_true_for_utf8_bom_file() { + let tmp = std::env::temp_dir().join("fselect_test_content_fields_bom_h"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + let mut content = vec![0xEFu8, 0xBB, 0xBF]; + content.extend_from_slice(b"data"); + fs::write(tmp.join("bom.txt"), content).unwrap(); + + let entry = entry_for(&tmp, "bom.txt"); + + assert_eq!(test_field(&entry, &tmp, &Field::HasBom).to_string(), "true"); + assert_eq!(test_field(&entry, &tmp, &Field::Encoding).to_string(), "UTF-8"); + + let _ = fs::remove_dir_all(&tmp); + } + + fn is_archive(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_archive, &default_config.is_archive) + } + + fn is_audio(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_audio, &default_config.is_audio) + } + + fn is_book(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_book, &default_config.is_book) + } + + fn is_doc(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_doc, &default_config.is_doc) + } + + fn is_font(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_font, &default_config.is_font) + } + + fn is_image(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_image, &default_config.is_image) + } + + fn is_source(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_source, &default_config.is_source) + } + + fn is_video(config: &Config, default_config: &Config, name: &str) -> bool { + check_extension(name, &config.is_video, &default_config.is_video) + } + + #[test] + fn test_is_archive() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_archive(&config, &default_config, "test.zip")); + assert!(is_archive(&config, &default_config, "test.tar")); + assert!(is_archive(&config, &default_config, "test.gz")); + assert!(is_archive(&config, &default_config, "test.rar")); + + assert!(!is_archive(&config, &default_config, "test.txt")); + assert!(!is_archive(&config, &default_config, "test.jpg")); + assert!(!is_archive(&config, &default_config, "test")); + } + + #[test] + fn test_is_audio() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_audio(&config, &default_config, "test.mp3")); + assert!(is_audio(&config, &default_config, "test.wav")); + assert!(is_audio(&config, &default_config, "test.flac")); + assert!(is_audio(&config, &default_config, "test.ogg")); + + assert!(!is_audio(&config, &default_config, "test.txt")); + assert!(!is_audio(&config, &default_config, "test.jpg")); + assert!(!is_audio(&config, &default_config, "test")); + } + + #[test] + fn test_is_book() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_book(&config, &default_config, "test.pdf")); + assert!(is_book(&config, &default_config, "test.epub")); + assert!(is_book(&config, &default_config, "test.mobi")); + assert!(is_book(&config, &default_config, "test.djvu")); + + assert!(!is_book(&config, &default_config, "test.txt")); + assert!(!is_book(&config, &default_config, "test.jpg")); + assert!(!is_book(&config, &default_config, "test")); + } + + #[test] + fn test_is_doc() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_doc(&config, &default_config, "test.doc")); + assert!(is_doc(&config, &default_config, "test.docx")); + assert!(is_doc(&config, &default_config, "test.pdf")); + assert!(is_doc(&config, &default_config, "test.xls")); + + assert!(!is_doc(&config, &default_config, "test.txt")); + assert!(!is_doc(&config, &default_config, "test.jpg")); + assert!(!is_doc(&config, &default_config, "test")); + } + + #[test] + fn test_is_font() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_font(&config, &default_config, "test.ttf")); + assert!(is_font(&config, &default_config, "test.otf")); + assert!(is_font(&config, &default_config, "test.woff")); + assert!(is_font(&config, &default_config, "test.woff2")); + + assert!(!is_font(&config, &default_config, "test.txt")); + assert!(!is_font(&config, &default_config, "test.jpg")); + assert!(!is_font(&config, &default_config, "test")); + } + + #[test] + fn test_is_image() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_image(&config, &default_config, "test.jpg")); + assert!(is_image(&config, &default_config, "test.png")); + assert!(is_image(&config, &default_config, "test.gif")); + assert!(is_image(&config, &default_config, "test.svg")); + + assert!(!is_image(&config, &default_config, "test.txt")); + assert!(!is_image(&config, &default_config, "test.mp3")); + assert!(!is_image(&config, &default_config, "test")); + } + + #[test] + fn test_is_source() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_source(&config, &default_config, "test.rs")); + assert!(is_source(&config, &default_config, "test.c")); + assert!(is_source(&config, &default_config, "test.cpp")); + assert!(is_source(&config, &default_config, "test.java")); + + assert!(!is_source(&config, &default_config, "test.txt")); + assert!(!is_source(&config, &default_config, "test.jpg")); + assert!(!is_source(&config, &default_config, "test")); + } + + #[test] + fn test_check_extension_both_none() { + assert!(!check_extension("test.zip", &None, &None)); + } + + #[test] + fn test_is_video() { + let config = Config::default(); + let default_config = Config::default(); + + assert!(is_video(&config, &default_config, "test.mp4")); + assert!(is_video(&config, &default_config, "test.avi")); + assert!(is_video(&config, &default_config, "test.mkv")); + assert!(is_video(&config, &default_config, "test.mov")); + + assert!(!is_video(&config, &default_config, "test.txt")); + assert!(!is_video(&config, &default_config, "test.jpg")); + assert!(!is_video(&config, &default_config, "test")); + } +} diff --git a/src/field/context.rs b/src/field/context.rs new file mode 100644 index 0000000..73c5210 --- /dev/null +++ b/src/field/context.rs @@ -0,0 +1,282 @@ +use std::collections::HashMap; +use std::fs::{DirEntry, FileType, Metadata}; +use std::path::Path; + +#[cfg(all(unix, feature = "users"))] +use uzers::UsersCache; + +use crate::config::Config; +use crate::fileinfo::FileInfo; +use crate::util::*; +#[cfg(feature = "git")] +use crate::util::git::GitCache; +use crate::util::audio::{AudioInfo, get_audio_info}; +use crate::util::dimensions::get_dimensions; +use crate::util::duration::get_duration; + +pub struct FileMetadataState { + pub(crate) file_metadata: Option>, + pub(crate) entry_file_type: Option>, + pub(crate) content_stats: Option>, + pub(crate) dimensions: Option>, + pub(crate) duration: Option>, + pub(crate) audio_info: Option>, + pub(crate) exif_metadata: Option>>, + pub(crate) mime_type: Option>, + pub(crate) sha1_hash: Option, + pub(crate) sha256_hash: Option, + pub(crate) sha512_hash: Option, + pub(crate) sha3_hash: Option, +} + +impl FileMetadataState { + pub fn new() -> FileMetadataState { + FileMetadataState { + file_metadata: None, + entry_file_type: None, + content_stats: None, + dimensions: None, + duration: None, + audio_info: None, + exif_metadata: None, + mime_type: None, + sha1_hash: None, + sha256_hash: None, + sha512_hash: None, + sha3_hash: None, + } + } + + pub fn clear(&mut self) { + *self = Self::new(); + } + + pub fn update_file_metadata(&mut self, entry: &DirEntry, follow_symlinks: bool) { + if self.file_metadata.is_none() { + self.file_metadata = Some(get_metadata(entry, follow_symlinks)); + } + } + + pub fn get_file_metadata(&self) -> Option<&Metadata> { + self.file_metadata.as_ref().and_then(|o| o.as_ref()) + } + + pub fn get_file_metadata_as_option(&self) -> &Option { + static NONE: Option = None; + self.file_metadata.as_ref().unwrap_or(&NONE) + } + + /// Whether a metadata load has already been attempted for the current file + /// (regardless of whether it succeeded). Lets type predicates reuse it + /// instead of issuing a fresh stat. + pub fn file_metadata_loaded(&self) -> bool { + self.file_metadata.is_some() + } + + /// Seed the entry's file type from a value the caller already resolved + /// (e.g. the directory traversal's descent check), so type predicates can + /// reuse it. A `None` hint is ignored, leaving the slot to be filled lazily + /// on first use. + pub fn seed_file_type(&mut self, file_type: Option) { + if file_type.is_some() { + self.entry_file_type = Some(file_type); + } + } + + /// The entry's file type, computed once and memoised. Reflects the entry + /// itself (symlinks are not followed), so it answers is_symlink directly + /// and is_dir/is_file only when not following symlinks. + pub fn get_or_compute_file_type(&mut self, entry: &DirEntry) -> Option { + if self.entry_file_type.is_none() { + self.entry_file_type = Some(entry.file_type().ok()); + } + self.entry_file_type.flatten() + } + + pub fn update_line_count(&mut self, entry: &DirEntry) { + // Always derived from the content-stats pass: a raw newline-byte scan + // disagrees with the decoded text for UTF-16/32 files (0x0A occurs + // inside multibyte code units), which made the reported count depend + // on which content field happened to be evaluated first. + self.update_content_stats(entry); + } + + pub fn get_line_count(&self) -> Option { + self.get_content_stats().map(|stats| stats.line_count) + } + + pub fn update_content_stats(&mut self, entry: &DirEntry) { + if self.content_stats.is_none() { + self.content_stats = Some(get_content_stats(entry)); + } + } + + pub fn get_content_stats(&self) -> Option<&ContentStats> { + self.content_stats.as_ref().and_then(|o| o.as_ref()) + } + + pub fn update_audio_info(&mut self, entry: &DirEntry) { + if self.audio_info.is_none() { + self.audio_info = Some(get_audio_info(&entry.path())); + } + } + + pub fn get_audio_info(&self) -> Option<&AudioInfo> { + self.audio_info.as_ref().and_then(|o| o.as_ref()) + } + + pub fn update_exif_metadata(&mut self, entry: &DirEntry) { + if self.exif_metadata.is_none() { + self.exif_metadata = Some(get_exif_metadata(entry)); + } + } + + pub fn get_exif_metadata(&self) -> Option<&HashMap> { + self.exif_metadata.as_ref().and_then(|o| o.as_ref()) + } + + pub fn get_exif_string(&mut self, entry: &DirEntry, key: &str) -> Option { + self.update_exif_metadata(entry); + self.get_exif_metadata() + .and_then(|info| info.get(key)) + .map(Variant::from_string) + } + + pub fn update_mime_type(&mut self, entry: &DirEntry) { + if self.mime_type.is_none() { + self.mime_type = Some( + tree_magic_mini::from_filepath(&entry.path()).map(String::from) + ); + } + } + + pub fn get_mime_type(&self) -> Option<&str> { + self.mime_type.as_ref().and_then(|o| o.as_deref()) + } + + pub fn get_or_compute_sha1(&mut self, entry: &DirEntry) -> &str { + if self.sha1_hash.is_none() { + self.sha1_hash = Some(get_sha1_file_hash(entry)); + } + self.sha1_hash.as_deref().unwrap() + } + + pub fn get_or_compute_sha256(&mut self, entry: &DirEntry) -> &str { + if self.sha256_hash.is_none() { + self.sha256_hash = Some(get_sha256_file_hash(entry)); + } + self.sha256_hash.as_deref().unwrap() + } + + pub fn get_or_compute_sha512(&mut self, entry: &DirEntry) -> &str { + if self.sha512_hash.is_none() { + self.sha512_hash = Some(get_sha512_file_hash(entry)); + } + self.sha512_hash.as_deref().unwrap() + } + + pub fn get_or_compute_sha3(&mut self, entry: &DirEntry) -> &str { + if self.sha3_hash.is_none() { + self.sha3_hash = Some(get_sha3_512_file_hash(entry)); + } + self.sha3_hash.as_deref().unwrap() + } + + pub fn update_dimensions(&mut self, entry: &DirEntry) { + if self.dimensions.is_none() { + self.dimensions = Some(get_dimensions(entry.path())); + } + } + + pub fn get_dimensions(&self) -> Option<&Dimensions> { + self.dimensions.as_ref().and_then(|o| o.as_ref()) + } + + pub fn update_duration(&mut self, entry: &DirEntry) { + if self.duration.is_none() { + // Audio durations come from lofty (via the cached audio info); + // anything it doesn't handle falls back to the video extractors. + self.update_audio_info(entry); + let duration = self + .get_audio_info() + .and_then(|info| info.duration) + .map(|length| Duration { length }) + .or_else(|| get_duration(entry.path())); + self.duration = Some(duration); + } + } + + pub fn get_duration(&self) -> Option<&Duration> { + self.duration.as_ref().and_then(|o| o.as_ref()) + } +} + +pub struct FieldContext<'a> { + pub entry: &'a DirEntry, + pub file_info: &'a Option, + pub root_path: &'a Path, + pub fms: &'a mut FileMetadataState, + #[cfg(feature = "git")] + pub git_cache: &'a mut GitCache, + pub follow_symlinks: bool, + pub config: &'a Config, + pub default_config: &'a Config, + #[cfg(all(unix, feature = "users"))] + pub user_cache: &'a UsersCache, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_metadata_state_new() { + let state = FileMetadataState::new(); + + assert!(state.file_metadata.is_none()); + assert!(state.entry_file_type.is_none()); + assert!(state.content_stats.is_none()); + assert!(state.dimensions.is_none()); + assert!(state.duration.is_none()); + assert!(state.audio_info.is_none()); + assert!(state.exif_metadata.is_none()); + assert!(state.mime_type.is_none()); + assert!(state.sha1_hash.is_none()); + assert!(state.sha256_hash.is_none()); + assert!(state.sha512_hash.is_none()); + assert!(state.sha3_hash.is_none()); + } + + #[test] + fn test_file_metadata_state_clear() { + let mut state = FileMetadataState::new(); + + state.file_metadata = Some(None); + state.entry_file_type = Some(None); + state.content_stats = Some(None); + state.dimensions = Some(None); + state.duration = Some(None); + state.audio_info = Some(None); + state.exif_metadata = Some(None); + state.mime_type = Some(None); + state.sha1_hash = Some(String::new()); + state.sha256_hash = Some(String::new()); + state.sha512_hash = Some(String::new()); + state.sha3_hash = Some(String::new()); + + state.clear(); + + assert!(state.file_metadata.is_none()); + assert!(state.entry_file_type.is_none()); + assert!(state.content_stats.is_none()); + assert!(state.dimensions.is_none()); + assert!(state.duration.is_none()); + assert!(state.audio_info.is_none()); + assert!(state.exif_metadata.is_none()); + assert!(state.mime_type.is_none()); + assert!(state.sha1_hash.is_none()); + assert!(state.sha256_hash.is_none()); + assert!(state.sha512_hash.is_none()); + assert!(state.sha3_hash.is_none()); + } +} diff --git a/src/field/dispatch.rs b/src/field/dispatch.rs new file mode 100644 index 0000000..4a39218 --- /dev/null +++ b/src/field/dispatch.rs @@ -0,0 +1,188 @@ +use crate::field::Field; +use crate::field::context::FieldContext; +use crate::field::{ + content_handlers, exif_handlers, git_handlers, hash_handlers, media_handlers, + metadata_handlers, mode_handlers, path_handlers, +}; +use crate::util::*; +use crate::util::error::SearchError; + +pub fn get_field_value(ctx: &mut FieldContext, field: &Field) -> Result { + if ctx.file_info.is_some() && !field.is_available_for_archived_files() { + return Ok(Variant::empty(VariantType::String)); + } + + match field { + // Path fields + Field::Name => path_handlers::handle_name(ctx), + Field::Filename => path_handlers::handle_filename(ctx), + Field::Extension => path_handlers::handle_extension(ctx), + Field::Path => path_handlers::handle_path(ctx), + Field::AbsPath => path_handlers::handle_abspath(ctx), + Field::Directory => path_handlers::handle_directory(ctx), + Field::AbsDir => path_handlers::handle_absdir(ctx), + + // Size / type metadata + Field::Size => metadata_handlers::handle_size(ctx), + Field::FormattedSize => metadata_handlers::handle_formatted_size(ctx), + Field::IsDir => metadata_handlers::handle_is_dir(ctx), + Field::IsFile => metadata_handlers::handle_is_file(ctx), + Field::IsSymlink => metadata_handlers::handle_is_symlink(ctx), + Field::LinkTarget => metadata_handlers::handle_link_target(ctx), + Field::IsBrokenSymlink => metadata_handlers::handle_is_broken_symlink(ctx), + Field::IsPipe => metadata_handlers::handle_is_pipe(ctx), + Field::IsCharacterDevice => metadata_handlers::handle_is_char_device(ctx), + Field::IsBlockDevice => metadata_handlers::handle_is_block_device(ctx), + Field::IsSocket => metadata_handlers::handle_is_socket(ctx), + Field::Device => metadata_handlers::handle_device(ctx), + Field::Rdev => metadata_handlers::handle_rdev(ctx), + Field::Inode => metadata_handlers::handle_inode(ctx), + Field::Blocks => metadata_handlers::handle_blocks(ctx), + Field::BlockSize => metadata_handlers::handle_blksize(ctx), + Field::Hardlinks => metadata_handlers::handle_hardlinks(ctx), + Field::Atime => metadata_handlers::handle_atime(ctx), + Field::AtimeNsec => metadata_handlers::handle_atime_nsec(ctx), + Field::Mtime => metadata_handlers::handle_mtime(ctx), + Field::MtimeNsec => metadata_handlers::handle_mtime_nsec(ctx), + Field::Ctime => metadata_handlers::handle_ctime(ctx), + Field::CtimeNsec => metadata_handlers::handle_ctime_nsec(ctx), + Field::Created => metadata_handlers::handle_created(ctx), + Field::Accessed => metadata_handlers::handle_accessed(ctx), + Field::Modified => metadata_handlers::handle_modified(ctx), + Field::IsHidden => metadata_handlers::handle_is_hidden(ctx), + Field::IsEmpty => metadata_handlers::handle_is_empty(ctx), + Field::HasXattrs => metadata_handlers::handle_has_xattrs(ctx), + Field::XattrCount => metadata_handlers::handle_xattr_count(ctx), + Field::Extattrs => metadata_handlers::handle_extattrs(ctx), + Field::HasExtattrs => metadata_handlers::handle_has_extattrs(ctx), + Field::Acl => metadata_handlers::handle_acl(ctx), + Field::HasAcl => metadata_handlers::handle_has_acl(ctx), + Field::DefaultAcl => metadata_handlers::handle_default_acl(ctx), + Field::HasDefaultAcl => metadata_handlers::handle_has_default_acl(ctx), + Field::HasCapabilities => metadata_handlers::handle_has_capabilities(ctx), + Field::Capabilities => metadata_handlers::handle_capabilities(ctx), + Field::IsShebang => metadata_handlers::handle_is_shebang(ctx), + + // Git + Field::IsGitRepo => git_handlers::handle_is_git_repo(ctx), + #[cfg(feature = "git")] + Field::IsGitTracked => git_handlers::handle_is_git_tracked(ctx), + #[cfg(feature = "git")] + Field::IsGitignored => git_handlers::handle_is_gitignored(ctx), + #[cfg(feature = "git")] + Field::GitStatus => git_handlers::handle_git_status(ctx), + #[cfg(feature = "git")] + Field::GitBranch => git_handlers::handle_git_branch(ctx), + #[cfg(feature = "git")] + Field::GitLastCommitHash => git_handlers::handle_git_last_commit_hash(ctx), + #[cfg(feature = "git")] + Field::GitLastCommitDate => git_handlers::handle_git_last_commit_date(ctx), + #[cfg(feature = "git")] + Field::GitLastCommitAuthor => git_handlers::handle_git_last_commit_author(ctx), + + // Mode / permissions + Field::Mode => mode_handlers::handle_mode(ctx), + Field::UserRead => mode_handlers::handle_user_read(ctx), + Field::UserWrite => mode_handlers::handle_user_write(ctx), + Field::UserExec => mode_handlers::handle_user_exec(ctx), + Field::UserAll => mode_handlers::handle_user_all(ctx), + Field::GroupRead => mode_handlers::handle_group_read(ctx), + Field::GroupWrite => mode_handlers::handle_group_write(ctx), + Field::GroupExec => mode_handlers::handle_group_exec(ctx), + Field::GroupAll => mode_handlers::handle_group_all(ctx), + Field::OtherRead => mode_handlers::handle_other_read(ctx), + Field::OtherWrite => mode_handlers::handle_other_write(ctx), + Field::OtherExec => mode_handlers::handle_other_exec(ctx), + Field::OtherAll => mode_handlers::handle_other_all(ctx), + Field::Suid => mode_handlers::handle_suid(ctx), + Field::Sgid => mode_handlers::handle_sgid(ctx), + Field::IsSticky => mode_handlers::handle_is_sticky(ctx), + Field::Uid => mode_handlers::handle_uid(ctx), + Field::Gid => mode_handlers::handle_gid(ctx), + #[cfg(all(unix, feature = "users"))] + Field::User => mode_handlers::handle_user(ctx), + #[cfg(all(unix, feature = "users"))] + Field::Group => mode_handlers::handle_group(ctx), + + // Media (dimensions, audio) + Field::Width => media_handlers::handle_width(ctx), + Field::Height => media_handlers::handle_height(ctx), + Field::Duration => media_handlers::handle_duration(ctx), + Field::Bitrate => media_handlers::handle_bitrate(ctx), + Field::Freq => media_handlers::handle_freq(ctx), + Field::Title => media_handlers::handle_title(ctx), + Field::Artist => media_handlers::handle_artist(ctx), + Field::Album => media_handlers::handle_album(ctx), + Field::Year => media_handlers::handle_year(ctx), + Field::Genre => media_handlers::handle_genre(ctx), + Field::Comment => media_handlers::handle_comment(ctx), + Field::Track => media_handlers::handle_track(ctx), + Field::Disc => media_handlers::handle_disc(ctx), + + // EXIF + Field::ExifDateTime | Field::ExifDateTimeOriginal => { + exif_handlers::handle_exif_datetime(ctx, field) + } + Field::ExifGpsAltitude => exif_handlers::handle_exif_gps_altitude(ctx), + Field::ExifGpsLatitude => exif_handlers::handle_exif_gps_latitude(ctx), + Field::ExifGpsLongitude => exif_handlers::handle_exif_gps_longitude(ctx), + Field::ExifMake + | Field::ExifModel + | Field::ExifSoftware + | Field::ExifVersion + | Field::ExifExposureTime + | Field::ExifAperture + | Field::ExifShutterSpeed + | Field::ExifFNumber + | Field::ExifIsoSpeed + | Field::ExifPhotographicSensitivity + | Field::ExifFocalLength + | Field::ExifLensMake + | Field::ExifLensModel + | Field::ExifDescription + | Field::ExifArtist + | Field::ExifCopyright + | Field::ExifOrientation + | Field::ExifFlash + | Field::ExifColorSpace + | Field::ExifExposureProgram + | Field::ExifExposureBias + | Field::ExifWhiteBalance + | Field::ExifMeteringMode + | Field::ExifSceneType + | Field::ExifContrast + | Field::ExifSaturation + | Field::ExifSharpness + | Field::ExifBodySerial + | Field::ExifLensSerial + | Field::ExifUserComment + | Field::ExifImageWidth + | Field::ExifImageHeight + | Field::ExifMaxAperture + | Field::ExifDigitalZoom => exif_handlers::handle_exif_string(ctx, field), + + // Content + Field::LineCount => content_handlers::handle_line_count(ctx), + Field::WordCount => content_handlers::handle_word_count(ctx), + Field::CharCount => content_handlers::handle_char_count(ctx), + Field::Encoding => content_handlers::handle_encoding(ctx), + Field::HasBom => content_handlers::handle_has_bom(ctx), + Field::LineEnding => content_handlers::handle_line_ending(ctx), + Field::Mime => content_handlers::handle_mime(ctx), + Field::IsBinary | Field::IsText => content_handlers::handle_is_binary_or_text(ctx, field), + Field::IsArchive + | Field::IsAudio + | Field::IsBook + | Field::IsDoc + | Field::IsFont + | Field::IsImage + | Field::IsSource + | Field::IsVideo => content_handlers::handle_is_type(ctx, field), + + // Hashes + Field::Sha1 => hash_handlers::handle_sha1(ctx), + Field::Sha256 => hash_handlers::handle_sha256(ctx), + Field::Sha512 => hash_handlers::handle_sha512(ctx), + Field::Sha3 => hash_handlers::handle_sha3(ctx), + } +} diff --git a/src/field/exif_handlers.rs b/src/field/exif_handlers.rs new file mode 100644 index 0000000..16db755 --- /dev/null +++ b/src/field/exif_handlers.rs @@ -0,0 +1,95 @@ +use crate::field::Field; +use crate::field::context::FieldContext; +use crate::util::*; +use crate::util::datetime::parse_datetime; +use crate::util::error::SearchError; + +pub fn handle_exif_datetime(ctx: &mut FieldContext, field: &Field) -> Result { + ctx.fms.update_exif_metadata(ctx.entry); + let key = match field { + Field::ExifDateTime => "DateTime", + Field::ExifDateTimeOriginal => "DateTimeOriginal", + _ => return Err(SearchError::fatal(format!("Unexpected field in handle_exif_datetime: {:?}", field))), + }; + + if let Some(exif_info) = ctx.fms.get_exif_metadata() + && let Some(exif_value) = exif_info.get(key) + && let Ok(exif_datetime) = parse_datetime(exif_value) { + return Ok(Variant::from_datetime(exif_datetime.0)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_exif_gps_altitude(ctx: &mut FieldContext) -> Result { + ctx.fms.update_exif_metadata(ctx.entry); + if let Some(exif_info) = ctx.fms.get_exif_metadata() + && let Some(exif_value) = exif_info.get("__Alt") + && let Ok(value) = exif_value.parse() { + return Ok(Variant::from_float(value)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_exif_gps_latitude(ctx: &mut FieldContext) -> Result { + ctx.fms.update_exif_metadata(ctx.entry); + if let Some(exif_info) = ctx.fms.get_exif_metadata() + && let Some(exif_value) = exif_info.get("__Lat") + && let Ok(value) = exif_value.parse() { + return Ok(Variant::from_float(value)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_exif_gps_longitude(ctx: &mut FieldContext) -> Result { + ctx.fms.update_exif_metadata(ctx.entry); + if let Some(exif_info) = ctx.fms.get_exif_metadata() + && let Some(exif_value) = exif_info.get("__Lng") + && let Ok(value) = exif_value.parse() { + return Ok(Variant::from_float(value)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_exif_string(ctx: &mut FieldContext, field: &Field) -> Result { + let key = match field { + Field::ExifMake => "Make", + Field::ExifModel => "Model", + Field::ExifSoftware => "Software", + Field::ExifVersion => "ExifVersion", + Field::ExifExposureTime => "ExposureTime", + Field::ExifAperture => "ApertureValue", + Field::ExifShutterSpeed => "ShutterSpeedValue", + Field::ExifFNumber => "FNumber", + Field::ExifIsoSpeed => "ISOSpeed", + Field::ExifPhotographicSensitivity => "PhotographicSensitivity", + Field::ExifFocalLength => "FocalLength", + Field::ExifLensMake => "LensMake", + Field::ExifLensModel => "LensModel", + Field::ExifDescription => "ImageDescription", + Field::ExifArtist => "Artist", + Field::ExifCopyright => "Copyright", + Field::ExifOrientation => "Orientation", + Field::ExifFlash => "Flash", + Field::ExifColorSpace => "ColorSpace", + Field::ExifExposureProgram => "ExposureProgram", + Field::ExifExposureBias => "ExposureBiasValue", + Field::ExifWhiteBalance => "WhiteBalance", + Field::ExifMeteringMode => "MeteringMode", + Field::ExifSceneType => "SceneCaptureType", + Field::ExifContrast => "Contrast", + Field::ExifSaturation => "Saturation", + Field::ExifSharpness => "Sharpness", + Field::ExifBodySerial => "BodySerialNumber", + Field::ExifLensSerial => "LensSerialNumber", + Field::ExifUserComment => "UserComment", + Field::ExifImageWidth => "PixelXDimension", + Field::ExifImageHeight => "PixelYDimension", + Field::ExifMaxAperture => "MaxApertureValue", + Field::ExifDigitalZoom => "DigitalZoomRatio", + _ => return Err(SearchError::fatal(format!("Unexpected field in handle_exif_string: {:?}", field))), + }; + if let Some(val) = ctx.fms.get_exif_string(ctx.entry, key) { + return Ok(val); + } + Ok(Variant::empty(VariantType::String)) +} diff --git a/src/field/git_handlers.rs b/src/field/git_handlers.rs new file mode 100644 index 0000000..abffe1b --- /dev/null +++ b/src/field/git_handlers.rs @@ -0,0 +1,77 @@ +//! Handlers for git-related fields. + +use crate::field::context::FieldContext; +use crate::util::*; +use crate::util::error::SearchError; + +#[cfg(feature = "git")] +use crate::util::git::status_to_string; + +pub fn handle_is_git_repo(ctx: &mut FieldContext) -> Result { + Ok(Variant::from_bool(ctx.entry.path().join(".git").exists())) +} + +#[cfg(feature = "git")] +pub fn handle_is_git_tracked(ctx: &mut FieldContext) -> Result { + let path = ctx.entry.path(); + Ok(Variant::from_bool( + ctx.git_cache.is_tracked(&path).unwrap_or(false), + )) +} + +#[cfg(feature = "git")] +pub fn handle_is_gitignored(ctx: &mut FieldContext) -> Result { + let path = ctx.entry.path(); + Ok(Variant::from_bool( + ctx.git_cache.is_ignored(&path).unwrap_or(false), + )) +} + +#[cfg(feature = "git")] +pub fn handle_git_status(ctx: &mut FieldContext) -> Result { + let path = ctx.entry.path(); + match ctx.git_cache.status(&path) { + Some(status) => Ok(Variant::from_string(&status_to_string(status).to_string())), + None => Ok(Variant::empty(VariantType::String)), + } +} + +#[cfg(feature = "git")] +pub fn handle_git_branch(ctx: &mut FieldContext) -> Result { + let path = ctx.entry.path(); + match ctx.git_cache.branch(&path) { + Some(branch) => Ok(Variant::from_string(&branch)), + None => Ok(Variant::empty(VariantType::String)), + } +} + +#[cfg(feature = "git")] +pub fn handle_git_last_commit_hash(ctx: &mut FieldContext) -> Result { + let path = ctx.entry.path(); + match ctx.git_cache.last_commit(&path) { + Some(commit) => Ok(Variant::from_string(&commit.hash)), + None => Ok(Variant::empty(VariantType::String)), + } +} + +#[cfg(feature = "git")] +pub fn handle_git_last_commit_date(ctx: &mut FieldContext) -> Result { + let path = ctx.entry.path(); + if let Some(commit) = ctx.git_cache.last_commit(&path) + && commit.time >= 0 + && let Some(naive) = system_time_to_naive_local( + std::time::UNIX_EPOCH + std::time::Duration::from_secs(commit.time as u64), + ) { + return Ok(Variant::from_datetime(naive)); + } + Ok(Variant::empty(VariantType::String)) +} + +#[cfg(feature = "git")] +pub fn handle_git_last_commit_author(ctx: &mut FieldContext) -> Result { + let path = ctx.entry.path(); + match ctx.git_cache.last_commit(&path) { + Some(commit) => Ok(Variant::from_string(&commit.author)), + None => Ok(Variant::empty(VariantType::String)), + } +} diff --git a/src/field/hash_handlers.rs b/src/field/hash_handlers.rs new file mode 100644 index 0000000..6cd87e3 --- /dev/null +++ b/src/field/hash_handlers.rs @@ -0,0 +1,23 @@ +use crate::field::context::FieldContext; +use crate::util::error::SearchError; +use crate::util::Variant; + +pub fn handle_sha1(ctx: &mut FieldContext) -> Result { + let hash = ctx.fms.get_or_compute_sha1(ctx.entry).to_string(); + Ok(Variant::from_string(&hash)) +} + +pub fn handle_sha256(ctx: &mut FieldContext) -> Result { + let hash = ctx.fms.get_or_compute_sha256(ctx.entry).to_string(); + Ok(Variant::from_string(&hash)) +} + +pub fn handle_sha512(ctx: &mut FieldContext) -> Result { + let hash = ctx.fms.get_or_compute_sha512(ctx.entry).to_string(); + Ok(Variant::from_string(&hash)) +} + +pub fn handle_sha3(ctx: &mut FieldContext) -> Result { + let hash = ctx.fms.get_or_compute_sha3(ctx.entry).to_string(); + Ok(Variant::from_string(&hash)) +} diff --git a/src/field/media_handlers.rs b/src/field/media_handlers.rs new file mode 100644 index 0000000..7599aca --- /dev/null +++ b/src/field/media_handlers.rs @@ -0,0 +1,93 @@ +use crate::field::context::FieldContext; +use crate::util::*; +use crate::util::error::SearchError; + +pub fn handle_width(ctx: &mut FieldContext) -> Result { + ctx.fms.update_dimensions(ctx.entry); + if let Some(&Dimensions { width, .. }) = ctx.fms.get_dimensions() { + return Ok(Variant::from_int(width as i64)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_height(ctx: &mut FieldContext) -> Result { + ctx.fms.update_dimensions(ctx.entry); + if let Some(&Dimensions { height, .. }) = ctx.fms.get_dimensions() { + return Ok(Variant::from_int(height as i64)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_duration(ctx: &mut FieldContext) -> Result { + ctx.fms.update_duration(ctx.entry); + if let Some(&Duration { length, .. }) = ctx.fms.get_duration() { + return Ok(Variant::from_int(length as i64)); + } + Ok(Variant::empty(VariantType::String)) +} + +/// Read a numeric audio property (bitrate, sample rate) into a variant. +fn audio_int( + ctx: &mut FieldContext, + select: impl Fn(&AudioInfo) -> Option, +) -> Result { + ctx.fms.update_audio_info(ctx.entry); + if let Some(info) = ctx.fms.get_audio_info() + && let Some(value) = select(info) { + return Ok(Variant::from_int(value as i64)); + } + Ok(Variant::empty(VariantType::String)) +} + +/// Read a string audio tag (title, artist, ...) into a variant. +fn audio_string( + ctx: &mut FieldContext, + select: impl Fn(&AudioInfo) -> Option<&String>, +) -> Result { + ctx.fms.update_audio_info(ctx.entry); + if let Some(info) = ctx.fms.get_audio_info() + && let Some(value) = select(info) { + return Ok(Variant::from_string(value)); + } + Ok(Variant::empty(VariantType::String)) +} + +pub fn handle_bitrate(ctx: &mut FieldContext) -> Result { + audio_int(ctx, |info| info.bitrate) +} + +pub fn handle_freq(ctx: &mut FieldContext) -> Result { + audio_int(ctx, |info| info.sample_rate) +} + +pub fn handle_year(ctx: &mut FieldContext) -> Result { + audio_int(ctx, |info| info.year) +} + +pub fn handle_title(ctx: &mut FieldContext) -> Result { + audio_string(ctx, |info| info.title.as_ref()) +} + +pub fn handle_artist(ctx: &mut FieldContext) -> Result { + audio_string(ctx, |info| info.artist.as_ref()) +} + +pub fn handle_album(ctx: &mut FieldContext) -> Result { + audio_string(ctx, |info| info.album.as_ref()) +} + +pub fn handle_genre(ctx: &mut FieldContext) -> Result { + audio_string(ctx, |info| info.genre.as_ref()) +} + +pub fn handle_comment(ctx: &mut FieldContext) -> Result { + audio_string(ctx, |info| info.comment.as_ref()) +} + +pub fn handle_track(ctx: &mut FieldContext) -> Result { + audio_string(ctx, |info| info.track.as_ref()) +} + +pub fn handle_disc(ctx: &mut FieldContext) -> Result { + audio_string(ctx, |info| info.disc.as_ref()) +} diff --git a/src/field/metadata_handlers.rs b/src/field/metadata_handlers.rs new file mode 100644 index 0000000..b6a952c --- /dev/null +++ b/src/field/metadata_handlers.rs @@ -0,0 +1,824 @@ +#![allow(unused_imports, unused_variables)] + +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; + +#[cfg(unix)] +use xattr::FileExt; + +use crate::field::context::FieldContext; +use crate::mode; +use crate::util::*; +use crate::util::error::SearchError; + +/// Defines a handler that reads a single integer value from the entry's Unix +/// metadata. The accessor (e.g. `ino`, `dev`) is only referenced on Unix, so the +/// whole metadata read is gated and other platforms fall back to an empty value. +macro_rules! unix_int_handler { + ($name:ident, $accessor:ident, $empty:expr) => { + pub fn $name(ctx: &mut FieldContext) -> Result { + #[cfg(unix)] + { + ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks); + if let Some(attrs) = ctx.fms.get_file_metadata() { + return Ok(Variant::from_int(attrs.$accessor() as i64)); + } + } + Ok(Variant::empty($empty)) + } + }; +} + +/// Defines the paired `