Compare commits

...

33 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 4b83987ea1 feat: Colorized hex dump for binary file previews (#291)
* feat: Colorized hex dump for binary file previews

* chore: Update docs for - feat: Colorized hex dump for binary file previews
2026-03-17 12:27:48 -07:00
Dmitriy Kovalenko c6cb66b597 fix: Windows dir locking (#293)
* fix: Windows dir locking

* chore: Update docs for - fix: Windows dir locking
2026-03-17 10:40:09 -07:00
George 64861f8142 chore(readme) Fix install-fff.sh link to install-mcp.sh (#283)
* install-fff.sh -> install.mcp.sh

* install-fff.sh -> install.mcp.sh
2026-03-15 08:00:18 -07:00
Mohamed Ibraheem eb5f2b3648 refactor: move change_indexing_directory to picker_ui module (#286)
* refactor: move change_indexing_directory to picker_ui module

closes #237

* chore: format

* chore: format
2026-03-15 07:59:38 -07:00
Dmitriy Kovalenko fcdf4a9172 feat(mcp): Make install script understand upgrade use case 2026-03-13 16:45:21 -07:00
Dmitriy Kovalenko 1001eb8b5e feat(mcp): Add file path fallback for grep (#281)
* feat(mcp): Add file path fallback for grep

* chore: Update docs for - feat(mcp): Add file path fallback for grep
2026-03-13 16:29:24 -07:00
Dmitriy Kovalenko f0ce2dd50d chore: fix format 2026-03-13 09:58:27 -07:00
Dmitriy Kovalenko 1c2a1c1204 fix(grep): file path single file constraint parsing (#280) 2026-03-12 17:10:28 -07:00
Salomon Popp 66bdfff454 fix: correct typo in vim.uv.fs_unlink() (#279) 2026-03-12 16:51:20 -07:00
Dmitriy Kovalenko 1e50f8df80 fix: Prevent mcp from running root large folders (#278) 2026-03-12 08:35:27 -07:00
Dmitriy Kovalenko 736c41ecd6 feat: MCP (#272)
* feat: MCP

* chore: Update docs for - feat: MCP

* feat: Perf optimizations

* chore: Update docs for - feat: Perf optimizations
2026-03-11 21:27:11 -07:00
Dmitriy Kovalenko ac8df4c9e4 chore: Update docs for - feat: Show the actual matches for typo resistant fuzzy search 2026-03-06 13:31:12 -08:00
Dmitriy Kovalenko e3e534f4ad feat: Show the actual matches for typo resistant fuzzy search 2026-03-06 13:31:12 -08:00
Dmitriy Kovalenko 764e3ecf18 fix: Race when openning many sequenital dirs
closes https://github.com/dmtrKovalenko/fff.nvim/issues/269
closes https://github.com/dmtrKovalenko/fff.nvim/issues/256
2026-03-06 13:31:12 -08:00
Nick Kadutskyi 10a27f9678 fix: proper git hl group for sign on selected file (#268) 2026-03-03 20:27:03 -08:00
Nico Domino 66e712e066 fix: prompt_position=bottom fix scrolling of viewport to top (#267) 2026-03-03 09:18:56 -08:00
Dmitriy Kovalenko c5a3f89c5e feat: Make zig optional for building (#266)
* feat: Make zig optional for building

* chore: Update docs for - feat: Make zig optional for building
2026-02-28 14:13:07 -08:00
bansheerubber 29f81a141f feat: add cursorlineopt config option for styling livegrep file preview (#262)
* feat: add cursorlineopt config option for styling livegrep file preview

* fix: better validation for junk `cursorlineopt` values
2026-02-27 21:00:30 -08:00
Dmitriy Kovalenko d2e3993398 feat: cross-compile for android (! for termux users) (#264)
* feat: cross-compile for android (! for termux users)

* chore: Update docs for - feat: cross-compile for android (! for termux users)
2026-02-27 20:57:35 -08:00
bansheerubber a411100fa4 chore(docs): replace flex = nil with flex = false in README.md (#263) 2026-02-27 17:57:26 -08:00
Italo Silva 0a993d7a30 feat!: Make layout flex on small screens & responsive (#255)
* feat: Add `flex` option for preview

* chore: Update docs for - feat: Add `flex` option for preview

* feat: Recalculate layout on resize

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-02-25 18:36:20 -08:00
Dmitriy Kovalenko 7c2d46f1e2 chore: Upgrade every used neovim deprecated lua apis (#261)
* chore: Do not use any neovim deprecated apis

* chore: Update docs for - chore: Do not use any neovim deprecated apis
2026-02-25 17:44:13 -08:00
Dmitriy Kovalenko c3ed9fb17b fix: Binary download integrity issues (#260)
* feat: cleanup hl groups resolution (#259)

* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution (#259)

* feat: cleanup hl groups resolution (#259)

* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution
2026-02-24 17:32:10 -08:00
Dmitriy Kovalenko 0f40c66eb7 feat: cleanup hl groups resolution (#259)
* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution
2026-02-24 09:54:24 -08:00
Nicolò Francesco Maria Spingola 7298978bcb fix(download): correctly parse certutil output on Windows (#258) 2026-02-23 06:57:07 -08:00
Federico 8f69f987a4 docs: update readme example keybindings (#253)
* docs: update readme example keybindings

* update indenntation

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 07:41:24 -08:00
Dmitriy Kovalenko 3e9b8655b7 fix: Atomic download checks for binaries + codesign for macos (#252) 2026-02-19 22:16:22 -08:00
Dmitriy Kovalenko 727935ede6 fix: Macos background watcher memory usage (#251)
* fix: Macos background watcher memory usage

closes https://github.com/dmtrKovalenko/fff.nvim/issues/239

Fixes a memory leak of the backgorund watcher on macos when the internal
fsevents buffer is droping certain elements

* chore: Update docs for - fix: Macos background watcher memory usage
2026-02-19 18:10:59 -08:00
Dmitriy Kovalenko 9a2612b1b5 feat: Grep perfromance and stability improvements (#235)
* feat: Allow many fff consumers over ffi

* parallize grep

* chore: Update docs for - parallize grep
2026-02-19 15:59:23 -08:00
Koichi Nakayamada 7c0d999144 fix: Use consistent emoji for frecency debug star indicator (#248) 2026-02-19 14:10:59 -08:00
Salomon Popp 81d98f6b9a fix: ignore window-scoped DirChanged events (#240) 2026-02-19 13:56:15 -08:00
Aashish Sharma 46e87e5928 fix(nix): warnings on flake (#246) 2026-02-18 10:19:51 -08:00
Timo Clasen 9bc928db65 feat: Support initial query for live_grep and find_files (#243)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:31:35 -08:00
115 changed files with 11996 additions and 3688 deletions
+3 -8
View File
@@ -10,12 +10,7 @@ rustflags = ["-C", "target-feature=-crt-static"]
[target.aarch64-unknown-linux-musl]
rustflags = ["-C", "target-feature=-crt-static"]
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
[target.aarch64-linux-android]
rustflags = [
"-C",
"linker=aarch64-linux-android-clang",
"-C",
"link-args=-rdynamic",
"-C",
"default-linker-libraries",
]
rustflags = ["-C", "link-args=-rdynamic"]
@@ -1,4 +1,4 @@
name: Lua E2E Tests
name: e2e Tests
on:
push:
@@ -9,11 +9,16 @@ on:
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13"
# Force Node 24 for all JS-based actions to avoid the libuv
# process_title assertion crash on Windows (known Node 20 bug).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lua-tests:
name: Lua E2E (${{ matrix.os }})
name: e2e (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# e2e tests could be flaky on CI so we do not block release creation if they failed
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
strategy:
fail-fast: false
matrix:
@@ -23,7 +28,8 @@ jobs:
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install Zig
uses: mlugg/setup-zig@v2
@@ -31,17 +37,17 @@ jobs:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
cache-key: "v1-lua-e2e"
cache-on-failure: false
cache-key: "v2-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}
- name: Build Rust binary (Windows)
if: matrix.target
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
- name: Copy binary to target/release (Windows)
if: matrix.target
@@ -68,7 +74,7 @@ jobs:
- name: Build Rust binary
if: ${{ !matrix.target }}
run: cargo build --release -p fff-nvim
run: cargo build --release -p fff-nvim --features zlob
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
@@ -82,6 +88,8 @@ jobs:
- name: Run Lua tests
shell: bash
run: |
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
run: make test-lua
- name: Run bun tests
shell: bash
run: make test-bun
+50
View File
@@ -0,0 +1,50 @@
name: Lua CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lua-ls:
name: lua-language-server type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Neovim
run: |
curl -L https://github.com/neovim/neovim/releases/download/v0.11.5/nvim-linux-x86_64.tar.gz -o /opt/nvim.tar.gz
mkdir /opt/nvim
tar xzf /opt/nvim.tar.gz -C /opt/nvim
mv /opt/nvim/nvim-linux-x86_64/* /opt/nvim
echo "/opt/nvim/bin" >> $GITHUB_PATH
- name: Install lua-language-server
run: |
curl -L "https://github.com/LuaLS/lua-language-server/releases/download/3.17.1/lua-language-server-3.17.1-linux-x64.tar.gz" -o /opt/lls.tar.gz
mkdir /opt/lls
tar -xzf /opt/lls.tar.gz -C /opt/lls
echo "/opt/lls/bin" >> $GITHUB_PATH
- name: Clone snacks.nvim
run: git clone --depth=1 https://github.com/folke/snacks.nvim /opt/snacks.nvim
- name: Run lua-language-server
run: lua-language-server --configpath .luarc.ci.json --check=.
luacheck:
name: luacheck lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install luacheck
run: |
sudo apt-get update -qq
sudo apt-get install -y luarocks
sudo luarocks install luacheck
- name: Run luacheck
run: luacheck lua/
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
# fetch last 2 commits required for auto force push back
fetch-depth: 2
+183 -20
View File
@@ -2,9 +2,12 @@ name: Prebuild
on:
push:
branches: [main, feat/binaries]
branches: [main, feat/mcp-ai]
pull_request:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-nvim:
name: Build Neovim ${{ matrix.target }}
@@ -36,6 +39,12 @@ jobs:
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
@@ -56,7 +65,7 @@ jobs:
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -64,7 +73,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
@@ -73,22 +82,40 @@ jobs:
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-nvim
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Upload artifacts
@@ -133,6 +160,13 @@ jobs:
lib_filename: libfff_c.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/release/libfff_c.so
lib_filename: libfff_c.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
@@ -162,7 +196,7 @@ jobs:
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -170,7 +204,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@v2
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
@@ -179,25 +213,43 @@ jobs:
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-c
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Prepare npm package
if: "!contains(matrix.target, 'android')"
shell: bash
run: |
# Copy the built binary into the platform npm package directory
@@ -210,19 +262,108 @@ jobs:
path: c-lib-${{ matrix.target }}.*
- name: Upload npm package artifact
if: "!contains(matrix.target, 'android')"
uses: actions/upload-artifact@v4
with:
name: npm-${{ matrix.npm_package }}
path: packages/${{ matrix.npm_package }}/
build-mcp:
name: Build MCP ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
include:
## Linux builds (using cargo-zigbuild)
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.17
artifact_name: target/x86_64-unknown-linux-gnu/release/fff-mcp
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.17
artifact_name: target/aarch64-unknown-linux-gnu/release/fff-mcp
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/fff-mcp
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/release/fff-mcp
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/release/fff-mcp
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/fff-mcp
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff-mcp.exe
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
- name: Install cargo-zigbuild
if: contains(matrix.os, 'ubuntu')
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
- name: Ad-hoc sign macOS binary
if: contains(matrix.os, 'macos')
run: codesign --force --sign - "fff-mcp-${{ matrix.target }}"
- name: Build for Windows
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mcp-${{ matrix.target }}
path: fff-mcp-${{ matrix.target }}*
release:
name: Release
needs: [build-nvim, build-c]
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
# do not create releases on the forks (no permissions)
if: github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dmtrKovalenko'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -259,6 +400,19 @@ jobs:
rmdir "$dir" 2>/dev/null || true
done
- name: Flatten MCP artifacts
working-directory: ./binaries
run: |
for dir in mcp-*/; do
for file in "$dir"*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
mv "$file" "./$filename"
fi
done
rmdir "$dir" 2>/dev/null || true
done
- name: Remove npm package artifacts from release binaries
working-directory: ./binaries
run: |
@@ -300,17 +454,25 @@ jobs:
## C FFI Library (for Bun/Node/Python)
- `c-lib-{target}.so` / `.dylib` / `.dll` - C FFI library
## MCP Server
- `fff-mcp-{target}` - MCP server binary
Install with:
```sh
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
```
npm-publish:
name: Publish npm packages
needs: [build-c]
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/binaries'))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/binaries'))
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/interchangable-ffi'))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/interchangable-ffi'))
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -393,9 +555,10 @@ jobs:
comment-on-pr:
name: Comment on PR
needs: [build-nvim, build-c]
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
# comments doesn't work on forks
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim' && github.event.pull_request.user.login == 'dmtrKovalenko'
permissions:
pull-requests: write
steps:
+12 -8
View File
@@ -20,16 +20,16 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: mlugg/setup-zig@v2
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
@@ -37,13 +37,15 @@ jobs:
components: rustfmt, clippy
- name: Run tests
run: cargo test --verbose --workspace --exclude fff-nvim
run: |
cargo test --verbose -p fff-core -p fff-query-parser -p fff-c --features zlob
cargo test --verbose -p grep-searcher
fmt:
name: cargo fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
@@ -56,11 +58,11 @@ jobs:
name: cargo clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: mlugg/setup-zig@v2
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
@@ -71,4 +73,6 @@ jobs:
components: clippy
- name: Run clippy
run: cargo clippy -- -D warnings
run: |
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
cargo clippy -p grep-searcher -- -D warnings
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
name: Check lua files using Stylua
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
+9
View File
@@ -13,3 +13,12 @@ result
big-repo/
# all the perf like utility files
*.data
node_modules/
dist/
scripts/benchmark-results/
# Native binaries (downloaded at install)
*.dylib
*.so
*.dll
+25
View File
@@ -0,0 +1,25 @@
-- luacheck configuration for fff.nvim
-- https://luacheck.readthedocs.io/en/stable/config.html
-- Neovim globals
globals = { "vim" }
-- Standard library
std = "luajit"
-- Ignore line length (handled by stylua)
max_line_length = false
-- Ignore unused self argument in methods
self = false
-- Files/directories to ignore
exclude_files = {
".luarocks/",
}
-- Warn about unused variables, but allow _ prefix convention
unused_args = true
ignore = {
"212", -- unused argument (too noisy for callback-heavy code)
}
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT",
"pathStrict": true
},
"workspace": {
"library": [
"/opt/nvim/share/nvim/runtime/lua/vim/_meta",
"/opt/nvim/share/nvim/runtime/lua/vim/shared.lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"/opt/snacks.nvim/lua"
],
"checkThirdParty": false
},
"diagnostics": {
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT"
},
"workspace": {
"library": [
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
"${3rd}/luv/library",
"${3rd}/busted/library"
],
"checkThirdParty": false
},
"diagnostics": {
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"fff": {
"type": "stdio",
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
"args": []
}
}
}
Generated
+556 -107
View File
@@ -45,12 +45,56 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -63,12 +107,29 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
@@ -93,7 +154,7 @@ dependencies = [
"bitflags 2.9.1",
"cexpr",
"clang-sys",
"itertools",
"itertools 0.10.5",
"log",
"prettyplease",
"proc-macro2",
@@ -154,6 +215,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cast"
version = "0.3.0"
@@ -252,6 +319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
@@ -260,8 +328,22 @@ version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -270,6 +352,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -294,7 +382,7 @@ dependencies = [
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
@@ -315,7 +403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
"itertools 0.10.5",
]
[[package]]
@@ -377,6 +465,40 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "deranged"
version = "0.4.0"
@@ -433,12 +555,24 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.13"
@@ -463,7 +597,6 @@ dependencies = [
"fff-query-parser",
"git2",
"mimalloc",
"once_cell",
"serde",
"serde_json",
"tracing",
@@ -474,6 +607,7 @@ name = "fff-core"
version = "0.1.0"
dependencies = [
"ahash",
"aho-corasick",
"bindet",
"blake3",
"chrono",
@@ -483,6 +617,7 @@ dependencies = [
"fff-query-parser",
"git2",
"glidesort",
"globset",
"grep-matcher",
"grep-searcher",
"heed",
@@ -493,23 +628,41 @@ dependencies = [
"notify",
"notify-debouncer-full 0.7.0",
"once_cell",
"openssl",
"parking_lot",
"pathdiff",
"rand",
"rayon",
"regex",
"serde",
"serde_json",
"smallvec",
"smartstring",
"tempfile",
"thiserror 2.0.12",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zlob",
]
[[package]]
name = "fff-mcp"
version = "0.1.0"
dependencies = [
"clap",
"fff-core",
"fff-query-parser",
"git2",
"mimalloc",
"rmcp",
"schemars",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "fff-nvim"
version = "0.1.0"
@@ -532,7 +685,6 @@ dependencies = [
"notify",
"notify-debouncer-full 0.6.0",
"once_cell",
"openssl",
"pathdiff",
"rand",
"rayon",
@@ -542,9 +694,6 @@ dependencies = [
"tempfile",
"thiserror 2.0.12",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zlob",
]
[[package]]
@@ -565,21 +714,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -598,6 +732,94 @@ dependencies = [
"libc",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -688,6 +910,18 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heed"
version = "0.22.0"
@@ -842,6 +1076,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.0.3"
@@ -879,6 +1119,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "inotify"
version = "0.11.0"
@@ -907,9 +1157,15 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
@@ -919,6 +1175,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -1159,36 +1424,14 @@ dependencies = [
"syn",
]
[[package]]
name = "multiversion"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
dependencies = [
"multiversion-macros",
"target-features",
]
[[package]]
name = "multiversion-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"target-features",
]
[[package]]
name = "neo_frizbee"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3f70b45907246d13fbe88cee200ebba81c9b77476028133a527c88bd875bc7"
checksum = "8c3404d265de0390885288ecf22fcdc4fb77519fe58168bac3d26ce65033b18b"
dependencies = [
"multiversion",
"rayon",
"itertools 0.14.0",
"raw-cpuid",
]
[[package]]
@@ -1294,60 +1537,18 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-src"
version = "300.5.1+3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -1393,6 +1594,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "pastey"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -1575,6 +1782,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.9.1",
]
[[package]]
name = "rayon"
version = "1.10.0"
@@ -1615,6 +1831,26 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regex"
version = "1.11.1"
@@ -1659,6 +1895,41 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rmcp"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2cb14cb9278a12eae884c9f3c0cfeca2cc28f361211206424a1d7abed95f090"
dependencies = [
"async-trait",
"base64",
"chrono",
"futures",
"pastey",
"pin-project-lite",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
"thiserror 2.0.12",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "rmcp-macros"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a02ea81d9482b07e1fe156ac7cf98b6823d51fb84531936a5e1cbb4eec31ad5"
dependencies = [
"darling",
"proc-macro2",
"quote",
"serde_json",
"syn",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -1705,6 +1976,32 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schemars"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1731,6 +2028,17 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
@@ -1743,6 +2051,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -1758,12 +2075,28 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -1782,6 +2115,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "socket2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -1794,6 +2137,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.104"
@@ -1825,12 +2174,6 @@ dependencies = [
"syn",
]
[[package]]
name = "target-features"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
[[package]]
name = "tempfile"
version = "3.20.0"
@@ -1944,6 +2287,88 @@ dependencies = [
"serde_json",
]
[[package]]
name = "tokio"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tracing"
version = "0.1.41"
@@ -2040,6 +2465,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
@@ -2274,6 +2705,15 @@ dependencies = [
"windows-targets 0.53.3",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -2460,6 +2900,15 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
@@ -2575,9 +3024,9 @@ dependencies = [
[[package]]
name = "zlob"
version = "1.2.9"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "674f4e74544c0a00887c0dff7862fe13742de8c5dbcf6db9eac0643362554d44"
checksum = "07934fb13f0f4e14281bab6b0e984cfc03891d2cf5b0a8bd0ab14fd240e21106"
dependencies = [
"bindgen",
"bitflags 2.9.1",
+9 -3
View File
@@ -2,6 +2,7 @@
members = [
"crates/fff-c",
"crates/fff-core",
"crates/fff-mcp",
"crates/fff-nvim",
"crates/fff-query-parser",
"crates/fff-searcher",
@@ -22,16 +23,17 @@ git2 = { version = "0.20.2", default-features = false, features = [
"vendored-libgit2",
] }
glidesort = "0.1"
globset = "0.4"
grep-matcher = "0.1.8"
grep-searcher = { path = "crates/fff-searcher" }
heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
zlob = "1.2.9"
zlob = "1.3.0"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = "0.8.1"
neo_frizbee = "0.8.2"
notify = "8.1.0"
notify-debouncer-full = "0.7"
once_cell = "1.20.2"
@@ -39,7 +41,11 @@ parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
regex = "1.11"
smallvec = { version = "1.13", features = ["const_generics", "union"] }
smallvec = { version = "1.13", features = [
"const_generics",
"union",
"may_dangle",
] }
thiserror = "2.0.10"
tracing = "0.1"
+32 -5
View File
@@ -1,9 +1,9 @@
PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-setup
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
build:
cargo build --release
cargo build --release --features zlob
test-setup:
@if [ ! -d "$(PLENARY_DIR)" ]; then \
@@ -12,17 +12,44 @@ test-setup:
fi
test-rust:
cargo test --verbose --workspace --exclude fff-nvim
cargo test --workspace --features zlob
test-lua: test-setup build
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
test: test-rust test-lua
prepare-bun: build
mkdir -p packages/fff-bun/bin
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null; \
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null; \
true
@# Re-sign on macOS: cp can invalidate ad-hoc code signatures
@if [ "$$(uname)" = "Darwin" ] && command -v codesign >/dev/null 2>&1; then \
codesign --sign - packages/fff-bun/bin/libfff_c.dylib 2>/dev/null || true; \
fi
test-bun: prepare-bun
cd packages/fff-bun && bun test src/
test: test-rust test-lua test-bun
format-rust:
cargo fmt --all
format-lua:
stylua .
format-ts:
bun format
format: format-rust format-lua
format: format-rust format-lua format-ts
lint-rust:
cargo clippy --workspace --features zlob -- -D warnings
lint-lua:
~/.luarocks/bin/luacheck .
lint-ts:
bun lint
lint: lint-rust lint-lua lint-ts
check: format lint
+104 -83
View File
@@ -1,9 +1,13 @@
<p align="center">
<h2 align="center">FFF.nvim</h2>
<h1 align="center">FFF</h1>
</p>
<p align="center">
Finally a smart fuzzy file picker for neovim.
<a href="#mcp"><strong>AI agents (MCP)</strong></a> &nbsp;&nbsp;|&nbsp;&nbsp; <a href="#neovim-guide"><strong>Neovim users</strong></a>
</p>
<p align="center">
<i>A fast file search for your AI and neovim, with memory built-in</i>
</p>
<p align="center" style="text-decoration: none; border: none;">
@@ -14,35 +18,44 @@
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none"> <img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
</p>
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
---
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
**FFF** stands for ~~freakin fast fuzzy file finder~~ (pick 3) and it is an opinionated fuzzy file picker for your AI agent and Neovim. Just for file search, but we do the file search really fff well.
## Features
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping with a strong focus on performance and useful search results. For humans - provides an unbelievable typo-resistant experience, for AI agents - implements the fastest file search with additional free memory suggesting the best search results based on various factors like frecency, git status, file size, definition matches, and more.
- Works out of the box with no additional configuration
- [Typo resistant fuzzy search](https://github.com/saghen/frizbee)
- Git status integration allowing to take advantage of last modified times within a worktree
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
- Display images in previews (for now requires snacks.nvim)
- Smart in a plenty of different ways hopefully helpful for your workflow
- This plugin initializes itself lazily by default
## MCP
## Installation
FFF is an amazing way to reduce the time and tokens by giving your AI agent a bit of memory built-in to their file search tools. It makes your AI harness to find the code faster and spend less tokens by doing less roundtrips and reading less useless files.
> [!NOTE]
> Although we'll try to make sure to keep 100% backward compatibility, by using you should understand that silly bugs and breaking changes may happen.
> And also we hope for your contributions and feedback to make this plugin ideal for everyone.
![Chart showing the superiority of fff.nvim over builtin claude code tools](./chart.png)
### Prerequisites
You can install FFF as a dependency for your AI agent using a simple bash script:
FFF.nvim requires:
```bash
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
```
- Neovim 0.10.0+
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
> The installation script is here [./install-mcp.sh](./install-mcp.sh) if you want to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
Here is an example addition to `CLAUDE.md` that works perfectly:
```sh
# CLAUDE.md
For any file search or grep in the current git indexed directory use fff tools
```
## Neovim guide
Here is some demo on the linux repository (100k files, 8GB) but you better fill it yourself and see the magic
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
### Installation
FFF.nvim requires neovim 0.10.0 or higher
#### lazy.nvim
```lua
@@ -78,12 +91,17 @@ FFF.nvim requires:
{
"fz",
function() require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' }
}
grep = {
modes = { 'fuzzy', 'plain' }
}
}) end,
desc = 'Live fffuzy grep',
}
},
{
"fc",
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
desc = 'Search current word',
},
}
}
```
@@ -136,6 +154,10 @@ require('fff').setup({
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
flex = { -- set to false to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
show_scrollbar = true, -- Show scrollbar for pagination
-- How to shorten long directory paths in the file list:
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
@@ -151,6 +173,7 @@ require('fff').setup({
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
@@ -170,27 +193,29 @@ require('fff').setup({
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
cycle_grep_modes = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
scrollbar = 'Comment',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
@@ -219,9 +244,10 @@ require('fff').setup({
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
},
-- Store file open frecency
frecency = {
@@ -232,32 +258,35 @@ require('fff').setup({
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
enabled = false, -- Show file info panel in preview
show_scores = false, -- Show scores inline in the UI
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- Live grep search configuration
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
}
})
},
})
```
### Key Features
@@ -265,19 +294,19 @@ require('fff').setup({
#### Available Methods
```lua
require('fff').find_files() -- Find files in current directory
require('fff').find_in_git_root() -- Find files in the current git repository
require('fff').find_files() -- Find files in current repository
require('fff').scan_files() -- Trigger rescan of files in the current directory
require('fff').refresh_git_status() -- Refresh git status for the active file lock
require('fff').refresh_git_status() -- Refresh git status for the active file list
require('fff').find_files_in_dir(path) -- Find files in a specific directory
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
```
just jump to the definition and see what other APIs are exposed we have a plenty
#### Commands
FFF.nvim provides several commands for interacting with the file picker:
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
- `:FFFScan` - Manually trigger a rescan of files in the current directory
- `:FFFRefreshGit` - Manually refresh git status for all files
- `:FFFClearCache [all|frecency|files]` - Clear various caches
@@ -285,10 +314,6 @@ FFF.nvim provides several commands for interacting with the file picker:
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
- `:FFFOpenLog` - Open the FFF log file in a new tab
#### Multiline Paste Support
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
#### Debug Mode
Toggle scoring information display:
@@ -342,10 +367,39 @@ require('fff').live_grep({
modes = { 'fuzzy' },
}
})
-- Pre-fill the search with an initial query
require('fff').live_grep({ query = 'search term' })
```
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
#### Constraints
There are a number of constraints you can use to refine your search in both grep and file search mode:
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
- `test/` - any deeply nested children of any test/ dir
- `!something` - exclude results matching something
- `!test/`, `!git:modified` - combining with any other constraint works as negation
- `./**/*.{rs,lua}` - any valid glob expression via [the fastest globbing library](https://github.com/dmtrKovalenko/zlob)
For grep only:
- `*.md`, `*.{c,h}` - extension filtering
- `src/main.rs` - grep in a single file
In addition to that, all constraints can be combined together like:
```
git:modified src/**/*.rs !src/**/mod.rs user controller
```
This will find all the files that qualify the constraints and:
- match **both** user and controller (for file mode)
- match "user controller" (for grep mode)
#### Cross-Mode Suggestions
When a search returns no results, FFF automatically queries the opposite search mode and displays the results as suggestions:
@@ -439,41 +493,8 @@ Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will
If you encounter issues, check the log file:
```vim
```
:FFFOpenLog
```
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
#### Common Issues
**File picker not initializing:**
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
- Check that your Neovim version is 0.10.0 or higher
**Image previews not working:**
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
- If using snacks.nvim, ensure it's properly configured
**Performance issues:**
- Adjust `max_threads` in configuration based on your system
- Reduce `preview.max_lines` and `preview.max_size` for large files
- Clear cache if it becomes too large: `:FFFClearCache all`
**Files not being indexed:**
- Run `:FFFScan` to manually trigger a file scan
- Check that the `base_path` is correctly set
- Verify you have read permissions for the directory
#### Debug Mode
Enable debug mode to see scoring information and troubleshoot search results:
- Press `F2` while in the picker
- Run `:FFFDebug on` to enable permanently
- Set `debug.show_scores = true` in configuration
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"files": {
"includes": ["packages/**/*.ts", "!packages/*/dist"],
"ignoreUnknown": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 90
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"complexity": {
"noForEach": "off"
}
}
}
}
+297
View File
@@ -0,0 +1,297 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"devDependencies": {
"@biomejs/biome": "^2.4.4",
},
},
"packages/fff-bun": {
"name": "@ff-labs/fff-bun",
"version": "0.1.37",
"bin": {
"fff": "./scripts/cli.ts",
"fff-demo": "./examples/search.ts",
"fff-grep": "./examples/grep.ts",
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0",
},
"optionalDependencies": {
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
"@ff-labs/fff-bun-win32-x64": "0.0.0",
},
"peerDependencies": {
"bun": ">=1.0.0",
},
},
"packages/fff-mcp": {
"name": "@ff-labs/fff-mcp",
"version": "0.1.0",
"bin": {
"fff-mcp": "./src/index.ts",
},
"dependencies": {
"@ff-labs/fff-bun": "workspace:*",
"@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.24.0",
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0",
},
},
},
"trustedDependencies": [
"@ff-labs/fff-bun",
],
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@workspace:packages/fff-bun"],
"@ff-labs/fff-mcp": ["@ff-labs/fff-mcp@workspace:packages/fff-mcp"],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bun": ["bun@1.3.10", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.10", "@oven/bun-darwin-x64": "1.3.10", "@oven/bun-darwin-x64-baseline": "1.3.10", "@oven/bun-linux-aarch64": "1.3.10", "@oven/bun-linux-aarch64-musl": "1.3.10", "@oven/bun-linux-x64": "1.3.10", "@oven/bun-linux-x64-baseline": "1.3.10", "@oven/bun-linux-x64-musl": "1.3.10", "@oven/bun-linux-x64-musl-baseline": "1.3.10", "@oven/bun-windows-aarch64": "1.3.10", "@oven/bun-windows-x64": "1.3.10", "@oven/bun-windows-x64-baseline": "1.3.10" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+4 -1
View File
@@ -8,9 +8,12 @@ license = "MIT"
[lib]
crate-type = ["cdylib"]
[features]
default = []
zlob = ["fff-core/zlob"]
[dependencies]
mimalloc.workspace = true
once_cell.workspace = true
tracing.workspace = true
git2.workspace = true
+69 -2
View File
@@ -3,7 +3,7 @@
//! These types use #[repr(C)] for C ABI compatibility and implement
//! serde traits for JSON serialization.
use std::ffi::{CString, c_char};
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use fff_core::git::format_git_status;
@@ -20,6 +20,8 @@ pub struct FffResult {
pub data: *mut c_char,
/// Error message on failure (null-terminated string, caller must free)
pub error: *mut c_char,
/// Opaque handle pointer (used by fff_create to return the instance)
pub handle: *mut c_void,
}
impl FffResult {
@@ -29,6 +31,7 @@ impl FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
@@ -38,6 +41,17 @@ impl FffResult {
success: true,
data: CString::new(data).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
/// Create a successful result carrying an opaque instance handle.
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle,
}))
}
@@ -47,6 +61,7 @@ impl FffResult {
success: false,
data: ptr::null_mut(),
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
}))
}
}
@@ -67,6 +82,10 @@ pub struct InitOptions {
/// grep search is as fast as subsequent ones (optional, defaults to false)
#[serde(default)]
pub warmup_mmap_cache: bool,
/// AI mode: automatically track frecency for all file modifications detected
/// by the background watcher (optional, defaults to false)
#[serde(default)]
pub ai_mode: bool,
}
/// Search options (JSON-deserializable)
@@ -226,6 +245,37 @@ impl SearchResultJson {
}
}
// ============================================================================
// Multi-grep (Aho-Corasick multi-pattern) types
// ============================================================================
/// Multi-grep search options (JSON-deserializable)
#[derive(Debug, Default, Deserialize)]
pub struct MultiGrepOptionsJson {
/// Patterns to search (OR logic — matches lines containing any pattern)
pub patterns: Vec<String>,
/// Optional constraint query like "*.rs" or "/src/"
pub constraints: Option<String>,
/// Maximum file size to search (bytes, default: 10MB)
pub max_file_size: Option<u64>,
/// Maximum matches per file (default: 0 = unlimited)
pub max_matches_per_file: Option<usize>,
/// Smart case: case-insensitive if all patterns are lowercase (default: true)
pub smart_case: Option<bool>,
/// File-based pagination offset (default: 0)
pub file_offset: Option<usize>,
/// Maximum matches to return per page (default: 50)
pub page_limit: Option<usize>,
/// Time budget in milliseconds, 0 = unlimited (default: 0)
pub time_budget_ms: Option<u64>,
/// Number of context lines before each match (default: 0)
pub before_context: Option<usize>,
/// Number of context lines after each match (default: 0)
pub after_context: Option<usize>,
/// Whether to classify matches as definition lines (default: false)
pub classify_definitions: Option<bool>,
}
// ============================================================================
// Grep (live search) types
// ============================================================================
@@ -247,6 +297,12 @@ pub struct GrepSearchOptionsJson {
pub mode: Option<String>,
/// Time budget in milliseconds, 0 = unlimited (default: 0)
pub time_budget_ms: Option<u64>,
/// Number of context lines before each match (default: 0)
pub before_context: Option<usize>,
/// Number of context lines after each match (default: 0)
pub after_context: Option<usize>,
/// Whether to classify matches as definition lines (default: false)
pub classify_definitions: Option<bool>,
}
/// A single grep match for JSON serialization
@@ -273,6 +329,14 @@ pub struct GrepMatchJson {
/// Fuzzy match score (only in fuzzy mode)
#[serde(skip_serializing_if = "Option::is_none")]
pub fuzzy_score: Option<u16>,
/// Whether the matched line is a code definition (struct, fn, class, etc.)
pub is_definition: bool,
/// Lines before the match (context)
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_before: Vec<String>,
/// Lines after the match (context)
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_after: Vec<String>,
}
impl GrepMatchJson {
@@ -298,6 +362,9 @@ impl GrepMatchJson {
.map(|&(start, end)| [start, end])
.collect(),
fuzzy_score: m.fuzzy_score,
is_definition: m.is_definition,
context_before: m.context_before.clone(),
context_after: m.context_after.clone(),
}
}
}
@@ -326,7 +393,7 @@ impl GrepResultJson {
GrepMatchJson::from_grep_match(m, file)
})
.collect(),
total_matched: result.total_match_count,
total_matched: result.matches.len(),
total_files_searched: result.total_files_searched,
total_files: result.total_files,
filtered_file_count: result.filtered_file_count,
+484 -291
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -13,6 +13,9 @@ crate-type = ["rlib", "staticlib", "cdylib"]
default = []
# Enable C FFI exports
ffi = []
# Use zlob (Zig-compiled C globbing library) for glob matching.
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
zlob = ["dep:zlob", "fff-query-parser/zlob"]
[dependencies]
# Workspace dependencies
@@ -23,7 +26,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff-query-parser = { path = "../fff-query-parser" }
fff-query-parser = { path = "../fff-query-parser", default-features = false }
# External dependencies
bindet = { workspace = true }
@@ -32,8 +35,10 @@ chrono = { workspace = true }
dirs = { workspace = true }
git2 = { workspace = true }
glidesort = { workspace = true }
globset = { workspace = true }
grep-matcher = { workspace = true }
grep-searcher = { workspace = true }
aho-corasick = "1"
memchr = "2"
heed = { workspace = true }
ignore = { workspace = true }
@@ -46,15 +51,12 @@ parking_lot = { workspace = true }
pathdiff = { workspace = true }
regex = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smartstring = { version = "1.0.1", features = ["serde"] }
toml = "0.8"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true }
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
zlob = { workspace = true, optional = true }
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
[target.'cfg(windows)'.dependencies]
dunce = { workspace = true }
+42 -12
View File
@@ -1,16 +1,46 @@
fn main() {
// When the `zlob` feature is enabled (Zig-compiled C library):
// On Windows MSVC, explicitly link the C runtime libraries.
// This is needed because Zig-compiled static libraries (zlob) don't emit
// /DEFAULTLIB directives for the MSVC CRT. Without this, symbols like
// strcmp, memcpy, memchr etc. from vendored C libraries (libgit2, lmdb)
// are unresolved when linking the cdylib.
//
// We link both msvcrt (classic CRT) and ucrt (Universal CRT where memchr,
// strcmp etc. live on newer MSVC/ARM64 targets).
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
}
} else if std::env::var("CI").is_ok() {
// CI must always build with zlob for production-quality binaries.
if !zig_available() {
panic!(
"CI detected but Zig is not installed. \
Please install Zig and build with `--features zlob`."
);
}
panic!(
"CI detected but `zlob` feature is not enabled. \
Build with `--features zlob`."
);
} else {
// Hint: if Zig is available but the zlob feature wasn't enabled,
// let the developer know they can get faster glob matching.
if zig_available() {
println!(
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
Build with `--features zlob` for faster glob matching."
);
}
}
}
/// Probe the system for a working Zig installation.
fn zig_available() -> bool {
std::process::Command::new("zig")
.arg("version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
+292 -99
View File
@@ -1,67 +1,131 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::file_picker::{FFFMode, FilePicker};
use crate::git::GitStatusCache;
use crate::sort_buffer::sort_with_buffer;
use crate::{SharedFrecency, SharedPicker};
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify_debouncer_full::{
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::{Level, error, info, warn};
use tracing::{Level, debug, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
/// Owns the file-system watcher and guarantees that all background threads
/// are fully joined before `stop()` / `Drop` returns.
///
/// Architecture:
/// - The debouncer (and its internal watcher) live inside an **owner thread**
/// that we spawn and hold the `JoinHandle` for.
/// - `stop()` sets a flag, unparks the owner thread, and **joins** it.
/// - Inside the owner thread, `Debouncer::stop()` is called which joins the
/// debouncer's event-processing thread.
/// - On Windows an additional short sleep is added after `Debouncer::stop()`
/// because `notify`'s `ReadDirectoryChangesWatcher` discards its thread
/// `JoinHandle`, so we cannot join it directly. The watcher's `Drop` does
/// signal the thread via semaphore so it exits almost immediately, but we
/// need to give the OS a moment to reclaim it.
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
stop_signal: Arc<AtomicBool>,
owner_thread: Option<std::thread::JoinHandle<()>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
const MAX_PATHS_THRESHOLD: usize = 1024;
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
/// Minimum seconds between frecency tracks of the same file in AI mode.
/// Prevents score inflation from rapid burst edits by AI agents.
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
impl BackgroundWatcher {
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
pub fn new(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}",
base_path.display()
"Initializing background watcher for path: {}, mode: {:?}",
base_path.display(),
mode,
);
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
let debouncer =
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
info!("Background file watcher initialized successfully");
let stop_signal = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop_signal);
// The owner thread keeps the debouncer alive and ensures proper
// cleanup: `Debouncer::stop()` joins its internal thread, then the
// watcher `Drop` signals its I/O thread to exit.
let owner_thread = std::thread::Builder::new()
.name("fff-watcher-owner".into())
.spawn(move || {
while !stop_clone.load(Ordering::Acquire) {
std::thread::park_timeout(Duration::from_secs(1));
}
// Debouncer::stop() joins the debouncer's event thread, then
// drops the watcher (whose Drop signals the I/O thread).
debouncer.stop();
// On Windows the notify crate discards the ReadDirectoryChangesW
// thread's JoinHandle — we cannot join it. Its Drop signals the
// thread via semaphore so it exits almost immediately; give the
// OS a moment to fully reclaim it.
#[cfg(windows)]
std::thread::sleep(Duration::from_millis(250));
})
.expect("failed to spawn fff-watcher-owner thread");
Ok(Self {
debouncer: Arc::new(Mutex::new(Some(debouncer))),
stop_signal,
owner_thread: Some(owner_thread),
})
}
fn create_debouncer(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
) -> Result<Debouncer, Error> {
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
let config = Config::default().with_follow_symlinks(false);
let git_workdir_for_handler = git_workdir.clone();
let mut debouncer = new_debouncer_opt(
DEBOUNCE_TIMEOUT,
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
{
move |result: DebounceEventResult| match result {
Ok(events) => {
handle_debounced_events(events, &git_workdir);
handle_debounced_events(
events,
&git_workdir_for_handler,
&shared_picker,
&shared_frecency,
mode,
);
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
},
RecommendedCache::new(),
// There is an issue with recommended cache implementation on macos
// it keeps track of all the files added to the watcher which is not a problem
// for us because any rename to the file will anyway require the removing from the
// ordedred index and adding it back with the new name
NoCache::new(),
config,
)?;
@@ -91,6 +155,11 @@ impl BackgroundWatcher {
}
}
}
// In selective mode the .git directory is excluded from the non-ignored
// dirs, but we still need to observe changes that affect git status
// (staging, unstaging, committing, branch switches, merges, etc.).
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
}
info!(
@@ -102,30 +171,34 @@ impl BackgroundWatcher {
Ok(debouncer)
}
pub fn stop(&self) {
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
drop(debouncer);
info!("Background file watcher stopped successfully");
} else {
error!("Failed to stop background watcher");
pub fn stop(&mut self) {
self.stop_signal.store(true, Ordering::Release);
if let Some(handle) = self.owner_thread.take() {
handle.thread().unpark();
if let Err(e) = handle.join() {
error!("Watcher owner thread panicked: {:?}", e);
}
}
info!("Background file watcher stopped successfully");
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
if let Some(debouncer) = debouncer_guard.take() {
drop(debouncer);
}
} else {
error!("Failed to acquire debouncer lock to drop");
}
self.stop();
}
}
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency), level = Level::DEBUG)]
fn handle_debounced_events(
events: Vec<DebouncedEvent>,
git_workdir: &Option<PathBuf>,
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
mode: FFFMode,
) {
// this will be called very often, we have to minimiy the lock time for file picker
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut need_full_rescan = false;
@@ -175,14 +248,30 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
if is_git_file(path) {
continue;
}
if !path.exists() {
// Use a combination of event kind and filesystem state to decide
// whether a path is an addition/modification or a removal.
//
// We cannot rely on `path.exists()` alone because:
// - A freshly created file might not be visible yet (race).
// - macOS FSEvents uses Modify(Name(Any)) for both rename-in
// and rename-out, so we must stat the path to disambiguate.
//
// We cannot rely on event kind alone because:
// - Remove events are not always emitted (macOS often sends
// Modify(Name(Any)) instead of Remove).
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
if is_removal || !path.exists() {
paths_to_remove.push(path.as_path());
} else {
paths_to_add_or_modify.push(path.as_path());
// For additions/modifications, still filter gitignored files.
if should_include_file(path, &repo) {
paths_to_add_or_modify.push(path.as_path());
}
}
}
@@ -204,7 +293,7 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
trigger_full_rescan();
trigger_full_rescan(shared_picker, shared_frecency);
return;
}
@@ -220,99 +309,164 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
paths_to_add_or_modify.len()
);
// Apply file index updates (add/remove) unconditionally — these must
// happen even when there is no git repository.
let files_to_update_git_status =
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
debug!(
"Applying file index changes: {} to remove, {} to add/modify",
paths_to_remove.len(),
paths_to_add_or_modify.len(),
);
let apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
for path in &paths_to_remove {
let removed = picker.remove_file_by_path(path);
debug!("remove_file_by_path({:?}) -> {}", path, removed);
}
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
for path in &paths_to_add_or_modify {
let result = picker.on_create_or_modify(path);
match result {
Some(file) => {
debug!(
"on_create_or_modify({:?}) -> Some({})",
path,
file.path.display()
);
files_to_update.push(file.path.clone());
}
None => {
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
}
}
}
info!(
"apply_changes complete: {} files to update git status",
files_to_update.len()
);
files_to_update
};
let Ok(mut guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *guard else {
error!("File picker not initialized");
return;
};
apply_changes(picker)
} else {
debug!("No file index changes to apply");
Vec::new()
};
// AI mode: auto-track frecency for all modified/created files.
// Uses a 5-minute cooldown per file to prevent score inflation from rapid
// burst edits (AI agents often edit the same file many times in minutes).
// This runs after apply_changes so the picker write lock is released.
if mode.is_ai() && !paths_to_add_or_modify.is_empty() {
let mut tracked_count = 0usize;
if let Ok(frecency_guard) = shared_frecency.read()
&& let Some(ref frecency) = *frecency_guard
{
for path in &paths_to_add_or_modify {
// Skip if this file was tracked less than 5 minutes ago
let should_track = match frecency.seconds_since_last_access(path) {
Ok(Some(secs)) => secs >= AI_MODE_COOLDOWN_SECS,
Ok(None) => true, // Never tracked before
Err(_) => true, // DB error, track anyway
};
if !should_track {
continue;
}
if let Err(e) = frecency.track_access(path) {
error!("Failed to track frecency for {:?}: {:?}", path, e);
} else {
tracked_count += 1;
}
}
if tracked_count > 0 {
info!("AI mode: tracked frecency for {} files", tracked_count);
}
}
// Update in-memory frecency scores for tracked files
if tracked_count > 0
&& let Ok(mut picker_guard) = shared_picker.write()
&& let Some(ref mut picker) = *picker_guard
&& let Ok(frecency_guard) = shared_frecency.read()
&& let Some(ref frecency) = *frecency_guard
{
for path in &paths_to_add_or_modify {
let _ = picker.update_single_file_frecency(path, frecency);
}
}
}
// Git status updates require a repository.
let Some(repo) = repo.as_ref() else {
info!("No git repo, skipping git status updates");
debug!("No git repo available, skipping git status updates");
return;
};
if need_full_git_rescan {
info!("Triggering full git rescan");
if let Err(e) = FilePicker::refresh_git_status_global() {
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
if let Err(e) = result {
error!("Failed to refresh git status: {:?}", e);
}
return;
}
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
return;
}
if !files_to_update_git_status.is_empty() {
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let files_to_update_git_status = {
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
// Apply file removals
for path in paths_to_remove {
picker.remove_file_by_path(path);
// No need to invalidate mmap — the FileItem (and its mmap) is dropped
}
// Apply file additions/modifications and collect paths for git status update
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
for path in paths_to_add_or_modify {
// on_create_or_modify clears the mmap internally when modified time changes
if let Some(file) = picker.on_create_or_modify(path) {
files_to_update_git_status.push(file.path.clone());
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git status");
return;
}
}
};
files_to_update_git_status
};
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git statue");
return;
}
};
// only lock the picker for theshortest possitble time
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
if let Err(e) = picker.update_git_statuses(status, shared_frecency) {
error!("Failed to update git statuses: {:?}", e);
} else {
info!("Successfully updated git statuses in picker");
}
} else {
info!("Successfully updated git statuses in picker");
error!("Failed to acquire picker lock for git status update");
}
} else {
error!("Failed to acquire picker lock for git status update");
}
}
fn trigger_full_rescan() {
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
info!("Triggering full filesystem rescan");
// Note: no need to clear mmaps — they are backed by the kernel page cache
// and automatically reflect file changes. Old FileItems (and their mmaps)
// are dropped when the picker rebuilds its file list.
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
let Ok(mut guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock for full rescan");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
let Some(ref mut picker) = *guard else {
error!("File picker not initialized, cannot trigger rescan");
return;
};
if let Err(e) = picker.trigger_rescan() {
if let Err(e) = picker.trigger_rescan(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
} else {
info!("Full filesystem rescan completed successfully");
@@ -320,12 +474,17 @@ fn trigger_full_rescan() {
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
if !path.is_file() || is_git_file(path) {
// Directories are not indexed — only regular files (and symlinks to files).
if path.is_dir() {
return false;
}
repo.as_ref()
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
// If there is a git repo, respect its ignore rules.
// If there is no repo (or the check fails), include the file.
match repo.as_ref() {
Some(repo) => repo.is_path_ignored(path) != Ok(true),
None => true,
}
}
#[inline]
@@ -375,6 +534,40 @@ fn is_ignore_definition_path(path: &Path) -> bool {
)
}
fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBuf>) {
let Some(workdir) = git_workdir else {
return;
};
let git_dir = workdir.join(".git");
if !git_dir.is_dir() {
return;
}
// Watch .git/ non-recursively to catch top-level files:
// index, index.lock, HEAD, packed-refs, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
warn!("Failed to watch .git directory: {}", e);
return;
}
// Watch refs/ recursively to catch branch/tag changes
let refs_dir = git_dir.join("refs");
if refs_dir.is_dir()
&& let Err(e) = debouncer.watch(&refs_dir, RecursiveMode::Recursive)
{
warn!("Failed to watch .git/refs: {}", e);
}
// Watch info/ non-recursively for exclude and sparse-checkout
let info_dir = git_dir.join("info");
if info_dir.is_dir()
&& let Err(e) = debouncer.watch(&info_dir, RecursiveMode::NonRecursive)
{
warn!("Failed to watch .git/info: {}", e);
}
}
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
/// selective file watching — only non-ignored directories get a recursive watcher,
+158 -28
View File
@@ -9,7 +9,6 @@
use ahash::AHashSet;
use fff_query_parser::{Constraint, GitStatusFilter};
use smallvec::SmallVec;
use zlob::{ZlobFlags, zlob_match_paths};
use crate::git::is_modified_status;
@@ -33,6 +32,28 @@ pub trait Constrainable {
fn git_status(&self) -> Option<git2::Status>;
}
/// Check if a relative path ends with the given suffix at a `/` boundary (case-insensitive).
///
/// Returns `true` when the path equals the suffix or the character before the suffix
/// in the path is `/`. This ensures partial directory-name matches are rejected.
///
/// Examples:
/// - `path_ends_with_suffix("libswscale/input.c", "libswscale/input.c")` → true (exact)
/// - `path_ends_with_suffix("foo/libswscale/input.c", "libswscale/input.c")` → true (suffix)
/// - `path_ends_with_suffix("xlibswscale/input.c", "libswscale/input.c")` → false (no boundary)
#[inline]
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
if path.len() < suffix.len() {
return false;
}
let start = path.len() - suffix.len();
if !path[start..].eq_ignore_ascii_case(suffix) {
return false;
}
// Exact match, or the character before is /
start == 0 || path.as_bytes()[start - 1] == b'/'
}
/// Check if file extension matches (without allocation)
#[inline]
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
@@ -45,12 +66,14 @@ pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
}
/// Check if path contains segment (without allocation)
/// Supports both single segments ("src") and multi-segment paths ("libswscale/aarch64").
/// For "libswscale/aarch64", checks that these appear as consecutive path components.
#[inline]
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
let path_bytes = path.as_bytes();
let segment_len = segment.len();
// Check segment/ at start
// Check segment/ at start of path
if path.len() > segment_len
&& path_bytes.get(segment_len) == Some(&b'/')
&& path[..segment_len].eq_ignore_ascii_case(segment)
@@ -102,6 +125,7 @@ fn item_matches_constraint_at_index<T: Constrainable>(
return if negate { !result } else { result };
}
Constraint::PathSegment(segment) => path_contains_segment(item.relative_path(), segment),
Constraint::FilePath(suffix) => path_ends_with_suffix(item.relative_path(), suffix),
Constraint::GitStatus(status_filter) => match (item.git_status(), status_filter) {
(Some(status), GitStatusFilter::Modified) => is_modified_status(status),
(Some(status), GitStatusFilter::Untracked) => status.contains(git2::Status::WT_NEW),
@@ -235,32 +259,8 @@ fn collect_glob_indices<'a>(
) {
match constraint {
Constraint::Glob(pattern) => {
if let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) {
let matched_set: AHashSet<usize> =
matches.iter().map(|s| s.as_ptr() as usize).collect();
let indices: AHashSet<usize> = if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
};
results.push((is_negated, indices));
} else {
results.push((is_negated, AHashSet::new()));
}
let indices = match_glob_pattern(pattern, paths);
results.push((is_negated, indices));
}
Constraint::Not(inner) => {
collect_glob_indices(inner, paths, results, !is_negated);
@@ -269,6 +269,66 @@ fn collect_glob_indices<'a>(
}
}
/// Match a glob pattern against a list of paths, returning the set of matching indices.
///
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
#[cfg(feature = "zlob")]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
else {
return AHashSet::new();
};
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
}
}
#[cfg(not(feature = "zlob"))]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(glob) = globset::Glob::new(pattern) else {
return AHashSet::new();
};
let matcher = glob.compile_matcher();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -312,8 +372,78 @@ mod tests {
// Should not match filename
assert!(!path_contains_segment("lib/src", "src"));
// Multi-segment constraints
assert!(path_contains_segment(
"libswscale/aarch64/input.S",
"libswscale/aarch64"
));
assert!(path_contains_segment(
"foo/libswscale/aarch64/input.S",
"libswscale/aarch64"
));
assert!(path_contains_segment(
"foo/LibSwscale/AArch64/input.S",
"libswscale/aarch64"
)); // case-insensitive
assert!(!path_contains_segment(
"xlibswscale/aarch64/input.S",
"libswscale/aarch64"
)); // partial match at start
assert!(!path_contains_segment(
"foo/libswscale/aarch64x/input.S",
"libswscale/aarch64"
)); // partial match at end
assert!(path_contains_segment(
"crates/fff-core/src/grep.rs",
"fff-core/src"
));
// Edge cases
assert!(!path_contains_segment("", "src"));
assert!(!path_contains_segment("src", "src")); // no trailing slash
}
#[test]
fn test_path_ends_with_suffix() {
// Exact match
assert!(path_ends_with_suffix(
"libswscale/input.c",
"libswscale/input.c"
));
// Suffix match at / boundary
assert!(path_ends_with_suffix(
"foo/libswscale/input.c",
"libswscale/input.c"
));
// Deep nesting
assert!(path_ends_with_suffix(
"a/b/c/libswscale/input.c",
"libswscale/input.c"
));
// No boundary — partial directory name
assert!(!path_ends_with_suffix(
"xlibswscale/input.c",
"libswscale/input.c"
));
// Case insensitive
assert!(path_ends_with_suffix(
"foo/LibSwscale/Input.C",
"libswscale/input.c"
));
// Single file name
assert!(path_ends_with_suffix("input.c", "input.c"));
assert!(!path_ends_with_suffix("xinput.c", "input.c"));
// Suffix longer than path
assert!(!path_ends_with_suffix("input.c", "foo/input.c"));
// Simple path
assert!(path_ends_with_suffix("src/main.rs", "src/main.rs"));
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
}
}
+321 -139
View File
@@ -5,6 +5,7 @@ use crate::git::GitStatusCache;
use crate::query_tracker::QueryMatchEntry;
use crate::score::match_and_score_files;
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
use crate::{SharedFrecency, SharedPicker};
use fff_query_parser::FFFQuery;
use git2::{Repository, Status, StatusOptions};
use rayon::prelude::*;
@@ -18,7 +19,18 @@ use std::sync::{
use std::time::SystemTime;
use tracing::{Level, debug, error, info, warn};
use crate::{FILE_PICKER, FRECENCY};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FFFMode {
#[default]
Neovim,
Ai,
}
impl FFFMode {
pub fn is_ai(self) -> bool {
self == FFFMode::Ai
}
}
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
@@ -52,7 +64,8 @@ pub struct FuzzySearchOptions<'a> {
#[derive(Debug, Clone)]
struct FileSync {
pub files: Vec<FileItem>,
/// Files sorted by path for binary search
files: Vec<FileItem>,
pub git_workdir: Option<PathBuf>,
}
@@ -64,9 +77,68 @@ impl FileSync {
}
}
/// Get all files (read-only). Files are sorted by path.
#[inline]
fn files(&self) -> &[FileItem] {
&self.files
}
fn get_file(&self, index: usize) -> Option<&FileItem> {
self.files.get(index)
}
/// Get mutable file at index
#[inline]
fn get_file_mut(&mut self, index: usize) -> Option<&mut FileItem> {
self.files.get_mut(index)
}
/// Find file index by path using binary search - O(log n)
#[inline]
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
self.files
.binary_search_by(|file| file.path.as_os_str().cmp(path.as_os_str()))
self.files.binary_search_by(|f| f.path.as_path().cmp(path))
}
/// Get file count
#[inline]
#[allow(dead_code)]
fn len(&self) -> usize {
self.files.len()
}
/// Insert a file at position. Simple - no HashMap to maintain!
fn insert_file(&mut self, position: usize, file: FileItem) {
self.files.insert(position, file);
}
/// Remove file at index. Simple - no HashMap to maintain!
fn remove_file(&mut self, index: usize) {
if index < self.files.len() {
self.files.remove(index);
}
}
/// Remove files matching predicate.
/// Returns number of files removed.
fn retain_files<F>(&mut self, predicate: F) -> usize
where
F: FnMut(&FileItem) -> bool,
{
let initial_len = self.files.len();
self.files.retain(predicate);
initial_len - self.files.len()
}
/// Insert a file in sorted order (by path).
/// Returns true if inserted, false if file already exists.
fn insert_file_sorted(&mut self, file: FileItem) -> bool {
match self.find_file_index(&file.path) {
Ok(_) => false, // File already exists
Err(position) => {
self.insert_file(position, file);
true
}
}
}
}
@@ -110,24 +182,18 @@ impl FileItem {
)
}
pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> {
self.access_frecency_score = tracker.get_access_score(&self.path);
pub fn update_frecency_scores(
&mut self,
tracker: &FrecencyTracker,
mode: FFFMode,
) -> Result<(), Error> {
self.access_frecency_score = tracker.get_access_score(&self.path, mode);
self.modification_frecency_score =
tracker.get_modification_score(self.modified, self.git_status);
tracker.get_modification_score(self.modified, self.git_status, mode);
self.total_frecency_score = self.access_frecency_score + self.modification_frecency_score;
Ok(())
}
/// Locks the tracker and updates frecensy score for one file. If need multiple files updates
/// use `update_frecency_scores` instead.
pub fn update_frecency_scores_global(&mut self) -> Result<(), Error> {
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
return Ok(());
};
self.update_frecency_scores(frecency)
}
}
pub struct FilePicker {
@@ -137,6 +203,8 @@ pub struct FilePicker {
scanned_files_count: Arc<AtomicUsize>,
background_watcher: Option<BackgroundWatcher>,
warmup_mmap_cache: bool,
cancelled: Arc<AtomicBool>,
mode: FFFMode,
}
impl std::fmt::Debug for FilePicker {
@@ -162,28 +230,38 @@ impl FilePicker {
self.warmup_mmap_cache
}
pub fn mode(&self) -> FFFMode {
self.mode
}
pub fn git_root(&self) -> Option<&Path> {
self.sync_data.git_workdir.as_deref()
}
/// Get all indexed files sorted by path.
/// Note: Files are stored sorted by PATH for efficient insert/remove.
/// For frecency-sorted results, use search() which sorts matched results.
pub fn get_files(&self) -> &[FileItem] {
&self.sync_data.files
self.sync_data.files()
}
pub fn new(base_path: String) -> Result<Self, Error> {
Self::with_options(base_path, false)
}
/// Create a new FilePicker with explicit options.
/// Create a new FilePicker and place it into the provided shared handle.
///
/// When `warmup_mmap_cache` is `true`, all non-binary files will be mmap'd
/// and their pages paged in immediately after the initial scan completes.
/// This makes the first grep search as fast as subsequent ones at the cost
/// of a longer startup time and higher initial memory pressure.
pub fn with_options(base_path: String, warmup_mmap_cache: bool) -> Result<Self, Error> {
/// The background scan thread and file-system watcher write into the
/// provided `SharedPicker` and read frecency data from the provided
/// `SharedFrecency`.
///
/// Multiple independent instances can coexist in the same process.
pub fn new_with_shared_state(
base_path: String,
warmup_mmap_cache: bool,
mode: FFFMode,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
) -> Result<(), Error> {
info!(
"Initializing FilePicker with base_path: {}, warmup: {}",
base_path, warmup_mmap_cache
"Initializing FilePicker with base_path: {}, warmup: {}, mode: {:?}",
base_path, warmup_mmap_cache, mode
);
let path = PathBuf::from(&base_path);
if !path.exists() {
@@ -191,26 +269,43 @@ impl FilePicker {
return Err(Error::InvalidPath(path));
}
let scan_signal = Arc::new(AtomicBool::new(false));
// Initialize scan_signal to `true` so that any `wait_for_scan` call
// that races with the background thread sees "scanning in progress"
// rather than a stale `false` (the thread hasn't started yet).
let scan_signal = Arc::new(AtomicBool::new(true));
let synced_files_count = Arc::new(AtomicUsize::new(0));
let cancelled = Arc::new(AtomicBool::new(false));
let picker = Self {
let picker = FilePicker {
base_path: path.clone(),
sync_data: FileSync::new(),
is_scanning: Arc::clone(&scan_signal),
scanned_files_count: Arc::clone(&synced_files_count),
background_watcher: None,
warmup_mmap_cache,
cancelled: Arc::clone(&cancelled),
mode,
};
// Place the picker into the shared handle before spawning the
// background thread so the thread can find it immediately.
{
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
*guard = Some(picker);
}
spawn_scan_and_watcher(
path.clone(),
Arc::clone(&scan_signal),
Arc::clone(&synced_files_count),
warmup_mmap_cache,
mode,
shared_picker,
shared_frecency,
cancelled,
);
Ok(picker)
Ok(())
}
/// Perform fuzzy search on files with a pre-parsed query.
@@ -226,16 +321,20 @@ impl FilePicker {
///
/// # Returns
/// SearchResult containing matched files, scores, and location information
pub fn fuzzy_search<'a>(
pub fn fuzzy_search<'a, 'q>(
files: &'a [FileItem],
query: &'a str,
parsed: Option<FFFQuery<'a>>,
options: FuzzySearchOptions<'a>,
query: &'q FFFQuery<'q>,
options: FuzzySearchOptions<'q>,
) -> SearchResult<'a> {
let max_threads = options.max_threads.max(1);
let max_threads = if options.max_threads == 0 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
} else {
options.max_threads
};
debug!(
?query,
parsed_is_some = parsed.is_some(),
raw_query = ?query.raw_query,
pagination = ?options.pagination,
?max_threads,
current_file = ?options.current_file,
@@ -244,25 +343,20 @@ impl FilePicker {
let total_files = files.len();
// Extract location from parsed query
let location = parsed.as_ref().and_then(|p| p.location);
let location = query.location;
// Get effective query for max_typos calculation (without location suffix)
let effective_query = match &parsed {
Some(p) => match &p.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query.trim(),
},
None => query.trim(),
let effective_query = match &query.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query.raw_query.trim(),
};
// small queries with a large number of results can match absolutely everything
let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
let context = ScoringContext {
raw_query: query,
parsed_query: parsed,
query,
project_path: options.project_path,
max_typos,
max_threads,
@@ -304,38 +398,47 @@ impl FilePicker {
}
}
pub fn update_git_statuses(&mut self, status_cache: GitStatusCache) -> Result<(), Error> {
/// Update git statuses for files, using the provided shared frecency tracker.
pub fn update_git_statuses(
&mut self,
status_cache: GitStatusCache,
shared_frecency: &SharedFrecency,
) -> Result<(), Error> {
debug!(
statuses_count = status_cache.statuses_len(),
"Updating git status",
);
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
let mode = self.mode;
let frecency = shared_frecency
.read()
.map_err(|_| Error::AcquireFrecencyLock)?;
status_cache
.into_iter()
.try_for_each(|(path, status)| -> Result<(), Error> {
if let Some(file) = self.get_mut_file_by_path(&path) {
file.git_status = Some(status);
if let Some(frecency) = frecency.as_ref() {
file.update_frecency_scores(frecency)?;
if let Some(ref f) = *frecency {
file.update_frecency_scores(f, mode)?;
}
} else {
error!(?path, "Couldn't update the git status for path");
}
Ok(())
})?;
Ok(())
}
/// Fetches all the git statuses first and updates the global FILE_PICKER
/// with the new statuses with the smallest possible lock time.
pub fn refresh_git_status_global() -> Result<usize, Error> {
/// Refreshes git statuses using the provided shared picker and frecency handles.
pub fn refresh_git_status(
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
) -> Result<usize, Error> {
let git_status = {
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
let guard = shared_picker.read().map_err(|_| Error::AcquireItemLock)?;
let Some(ref picker) = *guard else {
return Err(Error::FilePickerMissing);
};
debug!(
@@ -343,29 +446,22 @@ impl FilePicker {
picker.git_root()
);
// we keep here readonly lock but allowing querying the index while it scan lasts
GitStatusCache::read_git_status(
picker.git_root(),
StatusOptions::new()
.include_untracked(true)
.recurse_untracked_dirs(true)
// when manually refreshing git status we want to include all unmodified file
// to make sure that their status is correctly updated when user
// commited/stashed/removed changes
.include_unmodified(true)
.exclude_submodules(true),
)
};
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_mut()
.ok_or_else(|| Error::FilePickerMissing)?;
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
let statuses_count = if let Some(git_status) = git_status {
let count = git_status.statuses_len();
picker.update_git_statuses(git_status)?;
picker.update_git_statuses(git_status, shared_frecency)?;
count
} else {
0
@@ -380,9 +476,9 @@ impl FilePicker {
frecency_tracker: &FrecencyTracker,
) -> Result<(), Error> {
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
&& let Some(file) = self.sync_data.files.get_mut(index)
&& let Some(file) = self.sync_data.get_file_mut(index)
{
file.update_frecency_scores(frecency_tracker)?;
file.update_frecency_scores(frecency_tracker, self.mode)?;
}
Ok(())
@@ -392,35 +488,38 @@ impl FilePicker {
self.sync_data
.find_file_index(path.as_ref())
.ok()
.and_then(|index| self.sync_data.files.get(index))
.and_then(|index| self.sync_data.files().get(index))
}
pub fn get_mut_file_by_path(&mut self, path: impl AsRef<Path>) -> Option<&mut FileItem> {
self.sync_data
.find_file_index(path.as_ref())
.ok()
.and_then(|index| self.sync_data.files.get_mut(index))
.and_then(|index| self.sync_data.get_file_mut(index))
}
/// Add a file to the picker's files in sorted order (used by background watcher)
pub fn add_file_sorted(&mut self, file: FileItem) -> Option<&FileItem> {
match self
.sync_data
.files
.binary_search_by(|f| f.relative_path.cmp(&file.relative_path))
{
Ok(position) => {
warn!(
"Trying to insert a file that already exists: {}",
file.relative_path
);
let path = file.path.clone();
self.sync_data.files.get(position)
}
Err(position) => {
self.sync_data.files.insert(position, file);
self.sync_data.files.get(position)
}
if self.sync_data.insert_file_sorted(file) {
// File was inserted, look it up
self.sync_data
.find_file_index(&path)
.ok()
.and_then(|idx| self.sync_data.get_file_mut(idx))
.map(|file_mut| &*file_mut) // Convert &mut to &
} else {
// File already exists
warn!(
"Trying to insert a file that already exists: {}",
path.display()
);
self.sync_data
.find_file_index(&path)
.ok()
.and_then(|idx| self.sync_data.get_file_mut(idx))
.map(|file_mut| &*file_mut) // Convert &mut to &
}
}
@@ -429,8 +528,12 @@ impl FilePicker {
let path = path.as_ref();
match self.sync_data.find_file_index(path) {
Ok(pos) => {
// safe to read because we are in lock and binary search returned valid position
let file = &mut self.sync_data.files[pos];
debug!(
"on_create_or_modify: file EXISTS at index {}, updating metadata",
pos
);
// File exists - update its metadata (doesn't change indices, safe)
let file = self.sync_data.get_file_mut(pos)?;
let modified = match std::fs::metadata(path) {
Ok(metadata) => metadata
@@ -456,21 +559,40 @@ impl FilePicker {
}
}
Some(file)
Some(&*file) // Convert &mut to &
}
Err(pos) => {
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
self.sync_data.files.insert(pos, file_item);
debug!(
"on_create_or_modify: file NEW, inserting at index {} (total files: {})",
pos,
self.sync_data.files().len()
);
self.sync_data.files.get(pos)
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
let path_buf = file_item.path.clone();
self.sync_data.insert_file(pos, file_item);
let result = self.sync_data.get_file(pos);
if result.is_none() {
error!(
"on_create_or_modify: FAILED to find file after insert! path={:?}",
path_buf
);
} else {
debug!("on_create_or_modify: successfully inserted and found file");
}
result
}
}
}
pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
match self.sync_data.find_file_index(path.as_ref()) {
let path = path.as_ref();
match self.sync_data.find_file_index(path) {
Ok(index) => {
self.sync_data.files.remove(index);
self.sync_data.remove_file(index);
true
}
Err(_) => false,
@@ -480,22 +602,23 @@ impl FilePicker {
// TODO make this O(n)
pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
let dir_path = dir.as_ref();
let initial_len = self.sync_data.files.len();
// Use the safe retain_files method which maintains both indices
self.sync_data
.files
.retain(|file| !file.path.starts_with(dir_path));
.retain_files(|file| !file.path.starts_with(dir_path))
}
initial_len - self.sync_data.files.len()
/// We use this to prevent any substantial background threads from acquiring the locks
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
}
pub fn stop_background_monitor(&mut self) {
if let Some(watcher) = self.background_watcher.take() {
if let Some(mut watcher) = self.background_watcher.take() {
watcher.stop();
}
}
pub fn trigger_rescan(&mut self) -> Result<(), Error> {
pub fn trigger_rescan(&mut self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
if self.is_scanning.load(Ordering::Relaxed) {
debug!("Scan already in progress, skipping trigger_rescan");
return Ok(());
@@ -504,7 +627,12 @@ impl FilePicker {
self.is_scanning.store(true, Ordering::Relaxed);
self.scanned_files_count.store(0, Ordering::Relaxed);
let scan_result = scan_filesystem(&self.base_path, &self.scanned_files_count);
let scan_result = scan_filesystem(
&self.base_path,
&self.scanned_files_count,
shared_frecency,
self.mode,
);
match scan_result {
Ok(sync) => {
info!(
@@ -515,7 +643,11 @@ impl FilePicker {
self.sync_data = sync;
if self.warmup_mmap_cache {
warmup_mmaps(&self.sync_data.files);
// Warmup in background to avoid blocking
let files = self.sync_data.files().to_vec(); // Clone all files
std::thread::spawn(move || {
warmup_mmaps(&files);
});
}
}
Err(error) => error!(?error, "Failed to scan file system"),
@@ -528,6 +660,12 @@ impl FilePicker {
pub fn is_scan_active(&self) -> bool {
self.is_scanning.load(Ordering::Relaxed)
}
/// Return a clone of the scanning flag so callers can poll it without
/// holding a lock on the picker.
pub fn scan_signal(&self) -> Arc<AtomicBool> {
Arc::clone(&self.is_scanning)
}
}
#[allow(unused)]
@@ -537,33 +675,56 @@ pub struct ScanProgress {
pub is_scanning: bool,
}
#[allow(clippy::too_many_arguments)]
fn spawn_scan_and_watcher(
base_path: PathBuf,
scan_signal: Arc<AtomicBool>,
synced_files_count: Arc<AtomicUsize>,
warmup_mmap_cache: bool,
mode: FFFMode,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
cancelled: Arc<AtomicBool>,
) {
std::thread::spawn(move || {
scan_signal.store(true, Ordering::Relaxed);
// scan_signal is already `true` (set by the caller before spawning)
// so waiters see "scanning" even before this thread is scheduled.
info!("Starting initial file scan");
let mut git_workdir = None;
match scan_filesystem(&base_path, &synced_files_count) {
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency, mode) {
Ok(sync) => {
if cancelled.load(Ordering::Acquire) {
info!("Scan completed but picker was replaced, discarding results");
scan_signal.store(false, Ordering::Relaxed);
return;
}
info!(
"Initial filesystem scan completed: found {} files",
sync.files.len()
);
git_workdir = sync.git_workdir.clone();
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
picker.sync_data = sync;
if warmup_mmap_cache {
warmup_mmaps(&picker.sync_data.files);
// Write results into the provided shared handle.
let write_result = shared_picker.write().ok().map(|mut guard| {
if let Some(ref mut picker) = *guard {
picker.sync_data = sync;
}
});
if write_result.is_none() {
error!("Failed to write scan results into picker");
}
// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
if warmup_mmap_cache
&& !cancelled.load(Ordering::Acquire)
&& let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
{
warmup_mmaps(picker.sync_data.files());
}
}
Err(e) => {
@@ -572,14 +733,39 @@ fn spawn_scan_and_watcher(
}
scan_signal.store(false, Ordering::Relaxed);
match BackgroundWatcher::new(base_path, git_workdir) {
// Don't create a watcher if this picker instance was already replaced
if cancelled.load(Ordering::Acquire) {
info!("Picker was replaced, skipping background watcher creation");
return;
}
match BackgroundWatcher::new(
base_path,
git_workdir,
shared_picker.clone(),
shared_frecency.clone(),
mode,
) {
Ok(watcher) => {
info!("Background file watcher initialized successfully");
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
picker.background_watcher = Some(watcher);
// Final cancellation check: if the picker was replaced between
// watcher creation and this write, drop the watcher instead of
// storing it in the wrong picker.
if cancelled.load(Ordering::Acquire) {
info!("Picker was replaced, dropping orphaned watcher");
drop(watcher);
return;
}
let write_result = shared_picker.write().ok().map(|mut guard| {
if let Some(ref mut picker) = *guard {
picker.background_watcher = Some(watcher);
}
});
if write_result.is_none() {
error!("Failed to store background watcher in picker");
}
}
Err(e) => {
@@ -596,8 +782,8 @@ fn spawn_scan_and_watcher(
///
/// Each file is mmap'd and a single byte is read to trigger the page fault.
/// This runs in parallel using rayon.
#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
fn warmup_mmaps(files: &[FileItem]) {
let warmup_start = std::time::Instant::now();
let warmed = std::sync::atomic::AtomicUsize::new(0);
files.par_iter().for_each(|file| {
@@ -605,27 +791,21 @@ fn warmup_mmaps(files: &[FileItem]) {
return;
}
if let Some(mmap) = file.get_mmap() {
// Read the first byte to trigger the initial page fault, which
// causes the kernel to start readahead for subsequent pages.
// This is cheaper than madvise and portable across all platforms.
let _ = std::hint::black_box(mmap.first());
if let Some(content) = file.get_mmap() {
// Read the first byte to trigger the initial page fault (mmap)
// or ensure the content is cached (Windows buffer).
let _ = std::hint::black_box(content.first());
warmed.fetch_add(1, Ordering::Relaxed);
}
});
let warmed_count = warmed.load(Ordering::Relaxed);
info!(
"Mmap warmup completed: {warmed_count}/{} files in {:?}",
files.len(),
warmup_start.elapsed()
);
}
fn scan_filesystem(
base_path: &Path,
synced_files_count: &Arc<AtomicUsize>,
shared_frecency: &SharedFrecency,
mode: FFFMode,
) -> Result<FileSync, Error> {
use ignore::{WalkBuilder, WalkState};
use std::thread;
@@ -670,7 +850,7 @@ fn scan_filesystem(
.build_parallel();
let walker_start = std::time::Instant::now();
info!("SCAN: Starting file walker");
debug!("SCAN: Starting file walker");
let files = Arc::new(std::sync::Mutex::new(Vec::new()));
walker.run(|| {
@@ -712,7 +892,10 @@ fn scan_filesystem(
Error::ThreadPanic
})?;
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
let frecency = shared_frecency
.read()
.map_err(|_| Error::AcquireFrecencyLock)?;
files
.par_iter_mut()
.try_for_each(|file| -> Result<(), Error> {
@@ -721,7 +904,7 @@ fn scan_filesystem(
}
if let Some(frecency) = frecency.as_ref() {
file.update_frecency_scores(frecency)?;
file.update_frecency_scores(frecency, mode)?;
}
Ok(())
@@ -734,7 +917,6 @@ fn scan_filesystem(
files.len()
);
// Sort by OsStr instead of Path to avoid expensive component-by-component comparison
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
Ok(FileSync { files, git_workdir })
})
+251 -13
View File
@@ -1,11 +1,13 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::{error::Error, git::is_modified_status};
use crate::file_picker::FFFMode;
use crate::{SharedFrecency, error::Error, git::is_modified_status};
use heed::{Database, Env, EnvOpenOptions};
use heed::{
EnvFlags,
types::{Bytes, SerdeBincode},
};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::VecDeque, path::Path};
@@ -13,6 +15,10 @@ const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
const SECONDS_PER_DAY: f64 = 86400.0;
const MAX_HISTORY_DAYS: f64 = 30.0; // Only consider accesses within 30 days
// AI mode: faster decay since AI sessions are shorter and more intense
const AI_DECAY_CONSTANT: f64 = 0.231; // ln(2)/3 for 3-day half-life
const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
#[derive(Debug)]
pub struct FrecencyTracker {
env: Env,
@@ -27,6 +33,15 @@ const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(1, 60 * 60 * 24 * 7), // 1 week
];
// AI mode: compressed thresholds since AI edits happen in rapid bursts
const AI_MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(16, 30), // 30 seconds
(8, 60 * 5), // 5 minutes
(4, 60 * 15), // 15 minutes
(2, 60 * 60), // 1 hour
(1, 60 * 60 * 4), // 4 hours
];
impl DbHealthChecker for FrecencyTracker {
fn get_env(&self) -> &heed::Env {
&self.env
@@ -45,6 +60,7 @@ impl FrecencyTracker {
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let env = unsafe {
let mut opts = EnvOpenOptions::new();
opts.map_size(24 * 1024 * 1024); // 24 MiB
if use_unsafe_no_lock {
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
}
@@ -65,6 +81,200 @@ impl FrecencyTracker {
})
}
/// Spawns a background thread to purge stale frecency entries and compact the database.
///
/// Phase 1 (read lock): purge stale entries — deletes expired entries and prunes old timestamps.
/// Phase 2 (write lock): compact the database by re-writing entries into a fresh LMDB env.
/// We can't use LMDB's copy_to_path with NO_LOCK envs (MDB_INCOMPATIBLE),
/// so instead we: read all entries → drop env → delete files → reopen → write back.
pub fn spawn_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
std::thread::Builder::new()
.name("fff-frecency-gc".into())
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))
.ok();
}
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
fn run_frecency_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
let start = std::time::Instant::now();
let data_path = PathBuf::from(&db_path).join("data.mdb");
// Phase 1: Purge stale entries.
// The RwLock protects the Option<FrecencyTracker> (not the DB itself),
// so a read lock is sufficient — LMDB handles its own write serialization.
let (deleted, pruned) = {
let guard = match shared.read() {
Ok(g) => g,
Err(e) => {
tracing::debug!("Failed to acquire read lock: {e}");
return;
}
};
let Some(ref tracker) = *guard else {
return;
};
match tracker.purge_stale_entries() {
Ok(result) => result,
Err(e) => {
tracing::debug!("Purge failed: {e}");
return;
}
}
};
if deleted > 0 || pruned > 0 {
tracing::info!(deleted, pruned, elapsed = ?start.elapsed(), "Frecency GC purged entries");
}
// Compact if we purged entries OR the file has significant freelist bloat
let file_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
if deleted == 0 && pruned == 0 && file_size <= 512 * 1024 {
return;
}
// Phase 2: Manual compaction under a single write lock
let mut guard = match shared.write() {
Ok(g) => g,
Err(e) => {
tracing::debug!("Failed to acquire write lock: {e}");
return;
}
};
// Read all entries from current env
let entries: Vec<(Vec<u8>, VecDeque<u64>)> = match guard.as_ref() {
Some(tracker) => {
let rtxn = match tracker.env.read_txn() {
Ok(t) => t,
Err(e) => {
tracing::debug!("Compaction read_txn failed: {e}");
return;
}
};
let iter = match tracker.db.iter(&rtxn) {
Ok(i) => i,
Err(e) => {
tracing::debug!("Compaction iter failed: {e}");
return;
}
};
let mut entries = Vec::new();
let mut read_errors = 0u32;
for result in iter {
match result {
Ok((key, value)) => entries.push((key.to_vec(), value)),
Err(_) => read_errors += 1,
}
}
if read_errors > 0 {
tracing::warn!(
read_errors,
"Skipped corrupted entries during compaction read"
);
}
entries
}
None => return,
};
// Drop old tracker, delete files, create fresh env, write back
*guard = None;
let lock_path = PathBuf::from(&db_path).join("lock.mdb");
let _ = fs::remove_file(&data_path);
let _ = fs::remove_file(&lock_path);
let tracker = match FrecencyTracker::new(&db_path, use_unsafe_no_lock) {
Ok(t) => t,
Err(e) => {
tracing::error!("Compaction reopen failed, frecency disabled: {e}");
return;
}
};
let write_result = (|| -> std::result::Result<(), heed::Error> {
let mut wtxn = tracker.env.write_txn()?;
for (key, value) in &entries {
tracker.db.put(&mut wtxn, key.as_slice(), value)?;
}
wtxn.commit()?;
Ok(())
})();
match write_result {
Ok(()) => {
let new_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
*guard = Some(tracker);
tracing::debug!(
entries = entries.len(),
old_size = file_size,
new_size,
elapsed = ?start.elapsed(),
"Frecency DB compacted"
);
}
Err(e) => {
tracing::error!("Compaction write failed, frecency data may be incomplete: {e}");
*guard = Some(tracker);
}
}
}
/// Removes entries where all timestamps are older than MAX_HISTORY_DAYS,
/// and prunes stale timestamps from entries that still have recent ones.
/// Returns (deleted_count, pruned_count).
fn purge_stale_entries(&self) -> Result<(usize, usize), Error> {
let now = self.get_now();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
// Collect entries to delete or update
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let mut to_delete: Vec<Vec<u8>> = Vec::new();
let mut to_update: Vec<(Vec<u8>, VecDeque<u64>)> = Vec::new();
let iter = self.db.iter(&rtxn).map_err(Error::DbRead)?;
for result in iter {
let (key, accesses) = result.map_err(Error::DbRead)?;
// Timestamps are chronologically ordered (oldest at front).
// Find the first timestamp that is still within the retention window.
let fresh_start = accesses.iter().position(|&ts| ts >= cutoff_time);
match fresh_start {
None => {
// All timestamps are stale — delete the entire entry
to_delete.push(key.to_vec());
}
Some(0) => {
// All timestamps are fresh — nothing to do
}
Some(start) => {
// Some timestamps are stale — keep only the fresh ones
let pruned: VecDeque<u64> = accesses.iter().skip(start).copied().collect();
to_update.push((key.to_vec(), pruned));
}
}
}
drop(rtxn);
if to_delete.is_empty() && to_update.is_empty() {
return Ok((0, 0));
}
// Apply all changes in a single write transaction
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
for key in &to_delete {
self.db.delete(&mut wtxn, key).map_err(Error::DbWrite)?;
}
for (key, accesses) in &to_update {
self.db
.put(&mut wtxn, key, accesses)
.map_err(Error::DbWrite)?;
}
wtxn.commit().map_err(Error::DbCommit)?;
Ok((to_delete.len(), to_update.len()))
}
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
@@ -87,6 +297,14 @@ impl FrecencyTracker {
Ok(*blake3::hash(key.as_bytes()).as_bytes())
}
/// Returns seconds since the most recent tracked access, or `None` if the
/// file has never been tracked.
pub fn seconds_since_last_access(&self, path: &Path) -> Result<Option<u64>, Error> {
let accesses = self.get_accesses(path)?;
let last = accesses.and_then(|a| a.back().copied());
Ok(last.map(|ts| self.get_now().saturating_sub(ts)))
}
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
@@ -115,7 +333,7 @@ impl FrecencyTracker {
Ok(())
}
pub fn get_access_score(&self, file_path: &Path) -> i64 {
pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
let accesses = self
.get_accesses(file_path)
.ok()
@@ -126,10 +344,21 @@ impl FrecencyTracker {
return 0;
}
let decay_constant = if mode.is_ai() {
AI_DECAY_CONSTANT
} else {
DECAY_CONSTANT
};
let max_history_days = if mode.is_ai() {
AI_MAX_HISTORY_DAYS
} else {
MAX_HISTORY_DAYS
};
let now = self.get_now();
let mut total_frecency = 0.0;
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
let cutoff_time = now.saturating_sub((max_history_days * SECONDS_PER_DAY) as u64);
for &access_time in accesses.iter().rev() {
if access_time < cutoff_time {
@@ -137,7 +366,7 @@ impl FrecencyTracker {
}
let days_ago = (now.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
let decay_factor = (-DECAY_CONSTANT * days_ago).exp();
let decay_factor = (-decay_constant * days_ago).exp();
total_frecency += decay_factor;
}
@@ -155,24 +384,31 @@ impl FrecencyTracker {
&self,
modified_time: u64,
git_status: Option<git2::Status>,
mode: FFFMode,
) -> i64 {
let is_modified_git_status = git_status.is_some_and(is_modified_status);
if !is_modified_git_status {
return 0;
}
let thresholds = if mode.is_ai() {
&AI_MODIFICATION_THRESHOLDS
} else {
&MODIFICATION_THRESHOLDS
};
let now = self.get_now();
let duration_since = now.saturating_sub(modified_time);
for i in 0..MODIFICATION_THRESHOLDS.len() {
let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i];
for i in 0..thresholds.len() {
let (current_points, current_threshold) = thresholds[i];
if duration_since <= current_threshold {
if i == 0 || duration_since == current_threshold {
return current_points;
}
let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1];
let (prev_points, prev_threshold) = thresholds[i - 1];
let time_range = current_threshold - prev_threshold;
let time_offset = duration_since - prev_threshold;
@@ -192,6 +428,7 @@ impl FrecencyTracker {
#[cfg(test)]
mod tests {
use super::*;
use crate::file_picker::FFFMode;
fn calculate_test_frecency_score(access_timestamps: &[u64], current_time: u64) -> i64 {
let mut total_frecency = 0.0;
@@ -269,34 +506,35 @@ mod tests {
// At 5 minutes: should interpolate between 16 and 8 points
let five_minutes_ago = current_time - (5 * 60);
let score = tracker.get_modification_score(five_minutes_ago, git_status);
let score = tracker.get_modification_score(five_minutes_ago, git_status, FFFMode::Neovim);
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
let two_minutes_ago = current_time - (2 * 60);
let score = tracker.get_modification_score(two_minutes_ago, git_status);
let score = tracker.get_modification_score(two_minutes_ago, git_status, FFFMode::Neovim);
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
let fifteen_minutes_ago = current_time - (15 * 60);
let score = tracker.get_modification_score(fifteen_minutes_ago, git_status);
let score =
tracker.get_modification_score(fifteen_minutes_ago, git_status, FFFMode::Neovim);
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
// At 12 hours: should interpolate between 4 and 2 points
let twelve_hours_ago = current_time - (12 * 60 * 60);
let score = tracker.get_modification_score(twelve_hours_ago, git_status);
let score = tracker.get_modification_score(twelve_hours_ago, git_status, FFFMode::Neovim);
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
// at 18 hours for more significant interpolation
let eighteen_hours_ago = current_time - (18 * 60 * 60);
let score = tracker.get_modification_score(eighteen_hours_ago, git_status);
let score = tracker.get_modification_score(eighteen_hours_ago, git_status, FFFMode::Neovim);
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
let score = tracker.get_modification_score(five_minutes_ago, None);
let score = tracker.get_modification_score(five_minutes_ago, None, FFFMode::Neovim);
assert_eq!(score, 0, "No git status should return 0");
let _ = std::fs::remove_dir_all(&temp_dir);
+16 -12
View File
@@ -125,31 +125,35 @@ pub fn is_modified_status(status: Status) -> bool {
)
}
pub fn format_git_status(status: Option<Status>) -> &'static str {
pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
match status {
None => "clear",
None => Some("clean"),
Some(status) => {
if status.contains(Status::WT_NEW) {
"untracked"
Some("untracked")
} else if status.contains(Status::WT_MODIFIED) {
"modified"
Some("modified")
} else if status.contains(Status::WT_DELETED) {
"deleted"
Some("deleted")
} else if status.contains(Status::WT_RENAMED) {
"renamed"
Some("renamed")
} else if status.contains(Status::INDEX_NEW) {
"staged_new"
Some("staged_new")
} else if status.contains(Status::INDEX_MODIFIED) {
"staged_modified"
Some("staged_modified")
} else if status.contains(Status::INDEX_DELETED) {
"staged_deleted"
Some("staged_deleted")
} else if status.contains(Status::IGNORED) {
"ignored"
Some("ignored")
} else if status.contains(Status::CURRENT) || status.is_empty() {
"clean"
Some("clean")
} else {
"unknown"
None
}
}
}
}
pub fn format_git_status(status: Option<Status>) -> &'static str {
format_git_status_opt(status).unwrap_or("unknown")
}
+1362 -827
View File
File diff suppressed because it is too large Load Diff
+18 -15
View File
@@ -1,7 +1,13 @@
//! fff-core - High-performance file finder library
//!
//! This crate provides the core file indexing and fuzzy search functionality.
//! It maintains global state for the file picker, frecency tracker, and query tracker.
//!
//! # State management
//!
//! All state is instance-based. Callers create their own `SharedPicker` /
//! `SharedFrecency` / `SharedQueryTracker` and pass them into
//! `FilePicker::new_with_shared_state`. Multiple independent instances can
//! coexist in the same process.
mod background_watcher;
pub mod constraints;
@@ -11,6 +17,7 @@ pub mod file_picker;
pub mod frecency;
pub mod git;
pub mod grep;
pub mod log;
pub mod path_utils;
pub mod query_tracker;
pub mod score;
@@ -19,25 +26,21 @@ pub mod types;
use file_picker::FilePicker;
use frecency::FrecencyTracker;
use once_cell::sync::Lazy;
use query_tracker::QueryTracker;
use std::sync::RwLock;
use std::sync::{Arc, RwLock};
// Global state - same pattern as fff-nvim
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
// Re-export main types for convenience
pub use db_healthcheck::{DbHealth, DbHealthChecker};
pub use error::{Error, Result};
pub use file_picker::{FuzzySearchOptions, ScanProgress};
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
// Re-export grep types
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
// Re-export query parser types (including Location which moved there)
pub use fff_query_parser::{
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
};
pub use file_picker::{FFFMode, FuzzySearchOptions, ScanProgress};
pub use grep::{
GrepMatch, GrepMode, GrepResult, GrepSearchOptions, has_regex_metacharacters,
is_definition_line, is_import_line, multi_grep_search,
};
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
+164
View File
@@ -0,0 +1,164 @@
//! Shared logging utilities for FFF crates.
//!
//! Provides file-based tracing initialization and a panic hook that writes
//! to both stderr and a fallback log file.
use std::io;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// Install panic hook that writes to both stderr and a fallback file.
/// This is called separately from init_tracing to ensure panics are always logged.
pub fn install_panic_hook() {
PANIC_HOOK_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
// Always log to tracing (if initialized)
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF"
);
// Always print to stderr
eprintln!("=== FFF PANIC ===");
eprintln!("Message: {}", message);
eprintln!("Location: {}", location);
eprintln!("=================");
// Try to write to fallback panic log file
if let Some(cache_dir) = dirs::cache_dir() {
let panic_log = cache_dir.join("fff_panic.log");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let panic_entry = format!(
"\n[{}] PANIC at {}\nMessage: {}\n",
timestamp, location, message
);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&panic_log)
.and_then(|mut f| {
use std::io::Write;
f.write_all(panic_entry.as_bytes())
});
eprintln!("Panic logged to: {}", panic_log.display());
}
default_panic(panic_info);
}));
});
}
/// Parse a log level string into a `tracing::Level`.
///
/// Accepts "trace", "debug", "info", "warn", "error" (case-insensitive).
/// Returns `tracing::Level::INFO` for unrecognised values.
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
Some("trace") => tracing::Level::TRACE,
Some("debug") => tracing::Level::DEBUG,
Some("info") => tracing::Level::INFO,
Some("warn") => tracing::Level::WARN,
Some("error") => tracing::Level::ERROR,
_ => tracing::Level::INFO,
}
}
/// Initialize tracing with a single log file.
///
/// Creates the parent directory if it doesn't exist, truncates the log file,
/// and sets up a non-blocking file appender with structured formatting.
///
/// # Arguments
/// * `log_file_path` - Full path to the log file
/// * `log_level` - Log level (trace, debug, info, warn, error)
///
/// # Returns
/// * `Result<String, io::Error>` - Full path to the log file on success
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
// Install panic hook first (does nothing if already installed)
install_panic_hook();
let log_path = Path::new(log_file_path);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file_appender = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true) // creates a new file on every setup
.open(log_path)?;
let level = parse_log_level(log_level);
TRACING_INITIALIZED.get_or_init(|| {
let (non_blocking_appender, guard) = non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
.with_default_directive(level.into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF tracing initialized with log file: {}",
log_path.display()
);
}
guard
});
Ok(log_file_path.to_string())
}
+16
View File
@@ -17,6 +17,22 @@ pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
std::fs::canonicalize(path)
}
#[cfg(windows)]
pub fn expand_tilde(path: &str) -> PathBuf {
return PathBuf::from(path);
}
#[cfg(not(windows))]
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home_dir) = dirs::home_dir()
{
return home_dir.join(stripped);
}
PathBuf::from(path)
}
/// Calculate distance penalty based on directory proximity
/// Returns a negative penalty score based on how far the candidate is from the current file
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 {
+1
View File
@@ -65,6 +65,7 @@ impl QueryTracker {
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let env = unsafe {
let mut opts = EnvOpenOptions::new();
opts.map_size(10 * 1024 * 1024); // 100 MiB
opts.max_dbs(16); // Allow up to 16 databases per environment
if use_unsafe_no_lock {
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
+57 -36
View File
@@ -131,39 +131,34 @@ pub fn match_and_score_files<'a>(
return (vec![], vec![], 0);
}
let parsed = &context.parsed_query;
let working_files: FileItems<'a> = match parsed.as_ref().and_then(|p| {
if p.constraints.is_empty() {
None
} else {
apply_constraints(files, &p.constraints)
let parsed = context.query;
let working_files: FileItems<'a> = if parsed.constraints.is_empty() {
FileItems::All(files)
} else {
match apply_constraints(files, &parsed.constraints) {
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
Some(_) => {
return (vec![], vec![], 0);
}
None => FileItems::All(files),
}
}) {
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
Some(_) => {
return (vec![], vec![], 0);
}
None => FileItems::All(files),
};
let query_trimmed: &str = context.raw_query.trim();
let query_trimmed: &str = parsed.raw_query.trim();
let single_part_storage: [&str; 1] = [query_trimmed];
let fuzzy_parts: &[&str] = match parsed {
None => {
tracing::debug!("STEP 3: Query too short (<2 chars), returning frecency-sorted");
let fuzzy_parts: &[&str] = match &parsed.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
FuzzyQuery::Text(_) | FuzzyQuery::Parts(_) => {
return score_filtered_by_frecency(&working_files, context);
}
FuzzyQuery::Empty => {
if query_trimmed.len() < 2 {
return score_filtered_by_frecency(&working_files, context);
}
&single_part_storage
}
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
_ => {
return score_filtered_by_frecency(&working_files, context);
}
},
};
let has_uppercase = fuzzy_parts
@@ -172,7 +167,6 @@ pub fn match_and_score_files<'a>(
let query_contains_path_separator = fuzzy_parts.iter().any(|p| p.contains(MAIN_SEPARATOR));
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(context.max_typos),
sort: false,
scoring: Scoring {
@@ -230,6 +224,14 @@ pub fn match_and_score_files<'a>(
let mut base_score = path_match.score as i32;
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
// Give modified/dirty files a 15% boost to make them appear higher in results
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
base_score * 15 / 100
} else {
0
};
let distance_penalty =
calculate_distance_penalty(context.current_file, &file.relative_path);
@@ -296,6 +298,7 @@ pub fn match_and_score_files<'a>(
let total = base_score
.saturating_add(frecency_boost)
.saturating_add(git_status_boost)
.saturating_add(distance_penalty)
.saturating_add(filename_bonus)
.saturating_add(current_file_penalty)
@@ -312,12 +315,14 @@ pub fn match_and_score_files<'a>(
0
},
frecency_boost,
git_status_boost,
distance_penalty,
combo_match_boost,
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
match_type: match filename_match {
Some(filename_match) if filename_match.exact => "exact_filename",
Some(_) => "fuzzy_filename",
None if path_match.exact => "exact_path",
None => "fuzzy_path",
},
};
@@ -363,9 +368,18 @@ pub(crate) fn score_filtered_by_frecency<'a>(
let total_frecency_score = file.access_frecency_score as i32
+ (file.modification_frecency_score as i32).saturating_mul(4);
// Give modified/dirty files a boost even in frecency-only mode
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
total_frecency_score * 15 / 100
} else {
0
};
let current_file_penalty =
calculate_current_file_penalty(file, total_frecency_score, context);
let total = total_frecency_score.saturating_add(current_file_penalty);
let total = total_frecency_score
.saturating_add(git_status_boost)
.saturating_add(current_file_penalty);
let score = Score {
total,
@@ -376,6 +390,7 @@ pub(crate) fn score_filtered_by_frecency<'a>(
combo_match_boost: 0,
current_file_penalty,
frecency_boost: total_frecency_score,
git_status_boost,
exact_match: false,
match_type: "frecency",
};
@@ -482,10 +497,11 @@ fn sort_and_paginate<'a>(
mod tests {
use super::*;
use crate::types::PaginationArgs;
use fff_query_parser::QueryParser;
use std::path::PathBuf;
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
let file_name = path.split('/').last().unwrap_or(path).to_string();
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
let file = FileItem::new_raw(
PathBuf::from(path),
path.to_string(),
@@ -503,6 +519,7 @@ mod tests {
special_filename_bonus: 0,
current_file_penalty: 0,
frecency_boost: 0,
git_status_boost: 0,
exact_match: false,
match_type: "test",
combo_match_boost: 0,
@@ -532,9 +549,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
@@ -568,7 +587,7 @@ mod tests {
#[test]
fn test_partial_sort_with_same_scores() {
// Test tiebreaker with modified time
let test_data = vec![
let test_data = [
create_test_file("file1.rs", 100, 5000), // Same score, older
create_test_file("file2.rs", 100, 8000), // Same score, newer
create_test_file("file3.rs", 100, 3000), // Same score, oldest
@@ -581,9 +600,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
@@ -617,7 +638,7 @@ mod tests {
#[test]
fn test_no_partial_sort_for_small_results() {
// When results.len() <= threshold, should use regular sort
let test_data = vec![
let test_data = [
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
@@ -628,9 +649,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
@@ -666,7 +689,6 @@ mod multi_part_tests {
// Test with max_typos = 2 (safe for short needles)
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(2),
sort: false,
..Default::default()
@@ -698,7 +720,6 @@ mod multi_part_tests {
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs".to_lowercase();
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(2),
sort: false,
..Default::default()
+84 -47
View File
@@ -1,18 +1,41 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use memmap2::Mmap;
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
/// A single indexed file with metadata, frecency scores, and lazy mmap.
/// Cached file contents — mmap on Unix, heap buffer on Windows.
///
/// The `mmap` field holds the memory-mapped file contents, initialized lazily
/// on the first grep access and cached for subsequent searches. The mmap is
/// backed by the kernel page cache and automatically reflects file modifications
/// — no manual invalidation is needed.
/// On Windows, memory-mapped files hold the file handle open and prevent
/// editors from saving (writing/replacing) those files. Reading into a
/// `Vec<u8>` releases the handle immediately after the read completes.
#[derive(Debug)]
#[allow(dead_code)] // variants are conditionally used per platform
enum FileContent {
#[cfg(not(target_os = "windows"))]
Mmap(memmap2::Mmap),
#[cfg(target_os = "windows")]
Buffer(Vec<u8>),
}
impl std::ops::Deref for FileContent {
type Target = [u8];
fn deref(&self) -> &[u8] {
match self {
#[cfg(not(target_os = "windows"))]
FileContent::Mmap(m) => m,
#[cfg(target_os = "windows")]
FileContent::Buffer(b) => b,
}
}
}
/// A single indexed file with metadata, frecency scores, and lazy content cache.
///
/// File contents are initialized lazily on the first grep access and cached for
/// subsequent searches. On Unix, uses mmap backed by the kernel page cache. On
/// Windows, reads into a heap buffer to avoid holding file handles open.
///
/// Thread-safety: `OnceLock` provides lock-free reads after initialization.
/// Each file is only searched by one rayon worker at a time via `par_iter`.
@@ -30,10 +53,9 @@ pub struct FileItem {
pub total_frecency_score: i64,
pub git_status: Option<git2::Status>,
pub is_binary: bool,
/// Lazily-initialized memory-mapped file contents for grep.
/// Lazily-initialized file contents for grep.
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
/// Automatically reflects file changes via the kernel page cache.
mmap: OnceLock<Mmap>,
content: OnceLock<FileContent>,
}
impl Clone for FileItem {
@@ -51,8 +73,8 @@ impl Clone for FileItem {
total_frecency_score: self.total_frecency_score,
git_status: self.git_status,
is_binary: self.is_binary,
// Don't clone the mmap — the clone lazily re-creates it on demand
mmap: OnceLock::new(),
// Don't clone the content — the clone lazily re-creates it on demand
content: OnceLock::new(),
}
}
}
@@ -83,46 +105,65 @@ impl FileItem {
total_frecency_score: 0,
git_status,
is_binary,
mmap: OnceLock::new(),
content: OnceLock::new(),
}
}
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
/// Invalidate the cached content so the next `get_content()` call creates a fresh one.
///
/// Call this when the background watcher detects that the file has been modified.
/// While the kernel page cache reflects content changes automatically, a file
/// that is truncated (made smaller) while mapped can cause SIGBUS if the search
/// accesses pages beyond the new file size. Invalidating the mmap ensures a
/// fresh mapping with the correct size is created on the next access.
/// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
/// the stale buffer simply won't reflect the new contents. In both cases,
/// invalidating ensures a fresh read on the next access.
pub fn invalidate_mmap(&mut self) {
self.mmap = OnceLock::new();
self.content = OnceLock::new();
}
/// Get the cached mmap or lazily create it. Returns `None` if the file
/// is too large, empty, or can't be opened/mapped.
/// Get the cached file contents or lazily load them. Returns `None` if the
/// file is too large, empty, or can't be opened.
///
/// After the first call, this is lock-free (just an atomic load + pointer deref).
/// The mmap is backed by the kernel page cache and automatically reflects
/// file modifications — no manual invalidation is needed.
/// On Unix, uses mmap backed by the kernel page cache. On Windows, reads into
/// a heap buffer so the file handle is released immediately.
#[inline]
pub fn get_mmap(&self) -> Option<&Mmap> {
if let Some(mmap) = self.mmap.get() {
return Some(mmap);
pub fn get_content(&self) -> Option<&[u8]> {
if let Some(content) = self.content.get() {
return Some(content);
}
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
return None;
}
let file = std::fs::File::open(&self.path).ok()?;
let content = load_file_content(&self.path)?;
// If another thread raced us, OnceLock discards ours and returns theirs.
Some(self.content.get_or_init(|| content))
}
/// Backward-compatible alias for `get_content`.
#[inline]
pub fn get_mmap(&self) -> Option<&[u8]> {
self.get_content()
}
}
/// Load file contents: mmap on Unix, heap buffer on Windows.
fn load_file_content(path: &Path) -> Option<FileContent> {
#[cfg(not(target_os = "windows"))]
{
let file = std::fs::File::open(path).ok()?;
// SAFETY: The mmap is backed by the kernel page cache and automatically
// reflects file modifications. The only risk is SIGBUS if the file is
// truncated while mapped
let mmap = unsafe { Mmap::map(&file) }.ok()?;
// truncated while mapped.
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
Some(FileContent::Mmap(mmap))
}
// If another thread raced us, OnceLock discards our mmap and returns theirs.
// This is fine — the duplicate mmap is just dropped.
Some(self.mmap.get_or_init(|| mmap))
#[cfg(target_os = "windows")]
{
let data = std::fs::read(path).ok()?;
Some(FileContent::Buffer(data))
}
}
@@ -148,13 +189,14 @@ impl Constrainable for FileItem {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct Score {
pub total: i32,
pub base_score: i32,
pub filename_bonus: i32,
pub special_filename_bonus: i32,
pub frecency_boost: i32,
pub git_status_boost: i32,
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
@@ -170,15 +212,13 @@ pub struct PaginationArgs {
/// Context for scoring files during search.
///
/// The `parsed_query` field contains the pre-parsed query with constraints,
/// The `query` field contains the pre-parsed query with constraints,
/// fuzzy parts, and location information. Parsing is done once at the API
/// boundary and passed through.
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
/// The original raw query string (for compatibility and debugging)
pub raw_query: &'a str,
/// Pre-parsed query containing constraints, fuzzy parts, and location
pub parsed_query: Option<FFFQuery<'a>>,
/// Parsed query containing raw text, constraints, fuzzy parts, and location
pub query: &'a FFFQuery<'a>,
pub project_path: Option<&'a Path>,
pub current_file: Option<&'a str>,
pub max_typos: u16,
@@ -189,17 +229,14 @@ pub struct ScoringContext<'a> {
pub pagination: PaginationArgs,
}
impl<'a> ScoringContext<'a> {
impl ScoringContext<'_> {
/// Get the effective fuzzy query string for matching.
/// Returns the first fuzzy part, or the raw query if no parsing was done.
pub fn effective_query(&self) -> &'a str {
match &self.parsed_query {
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.raw_query.trim(),
},
None => self.raw_query.trim(),
pub fn effective_query(&self) -> &str {
match &self.query.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.query.raw_query.trim(),
}
}
}
+227 -78
View File
@@ -25,6 +25,9 @@ fn plain_opts() -> GrepSearchOptions {
page_limit: 200,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
}
}
@@ -38,6 +41,9 @@ fn regex_opts() -> GrepSearchOptions {
page_limit: 200,
mode: GrepMode::Regex,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
}
}
@@ -51,6 +57,9 @@ fn fuzzy_opts() -> GrepSearchOptions {
page_limit: 200,
mode: GrepMode::Fuzzy,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
}
}
@@ -63,7 +72,8 @@ fn plain_text_finds_exact_literal() {
"Hello, World!\nGoodbye, World!\n",
)];
let result = grep_search(&files, "Hello", None, &plain_opts());
let parsed = parse_grep_query("Hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -80,7 +90,8 @@ fn plain_text_smart_case_insensitive() {
)];
// All lowercase query → smart case → case-insensitive
let result = grep_search(&files, "hello", None, &plain_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -99,7 +110,8 @@ fn plain_text_smart_case_sensitive_with_uppercase() {
)];
// Query has uppercase → smart case → case-sensitive
let result = grep_search(&files, "Hello", None, &plain_opts());
let parsed = parse_grep_query("Hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -119,13 +131,15 @@ fn plain_text_regex_metacharacters_are_literal() {
)];
// In plain text mode, these regex metacharacters should be literal
let result = grep_search(&files, "fn main()", None, &plain_opts());
let parsed = parse_grep_query("fn main()");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
// Parentheses should NOT be treated as regex groups
let result2 = grep_search(&files, "(\"test\")", None, &plain_opts());
let parsed2 = parse_grep_query("(\"test\")");
let result2 = grep_search(&files, &parsed2, &plain_opts());
assert_eq!(result2.matches.len(), 1);
assert_eq!(result2.matches[0].line_number, 2);
}
@@ -140,7 +154,8 @@ fn plain_text_dot_is_literal() {
)];
// In plain text mode, dot should be literal, not "any char"
let result = grep_search(&files, "1.0", None, &plain_opts());
let parsed = parse_grep_query("1.0");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -159,7 +174,8 @@ fn plain_text_asterisk_is_literal() {
"Use **bold** text\nUse *italic* text\nUse normal text\n",
)];
let result = grep_search(&files, "**bold**", None, &plain_opts());
let parsed = parse_grep_query("**bold**");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
}
@@ -173,7 +189,8 @@ fn plain_text_backslash_is_literal() {
"C:\\Users\\foo\\bar\n/home/user/bin\n",
)];
let result = grep_search(&files, "C:\\Users", None, &plain_opts());
let parsed = parse_grep_query("C:\\Users");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
}
@@ -186,9 +203,9 @@ fn plain_text_across_multiple_files() {
create_file(tmp.path(), "c.txt", "no match here\n"),
];
let result = grep_search(&files, "use std", None, &plain_opts());
let parsed = parse_grep_query("use std");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.total_match_count, 3);
assert_eq!(result.matches.len(), 3);
// Should match in files a.txt and b.txt
assert_eq!(result.files.len(), 2);
@@ -199,7 +216,8 @@ fn plain_text_highlight_offsets_are_correct() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "foo bar foo baz foo\n")];
let result = grep_search(&files, "foo", None, &plain_opts());
let parsed = parse_grep_query("foo");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -216,7 +234,8 @@ fn plain_text_empty_query_returns_no_content_matches() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "some content\n")];
let result = grep_search(&files, "", None, &plain_opts());
let parsed = parse_grep_query("");
let result = grep_search(&files, &parsed, &plain_opts());
// Empty query in grep returns git-modified welcome state (no content matches)
// Since our test files have no git status, we expect 0 matches
@@ -237,7 +256,8 @@ fn plain_text_binary_files_are_skipped() {
let files = vec![binary_file, text_file];
let result = grep_search(&files, "match this text", None, &plain_opts());
let parsed = parse_grep_query("match this text");
let result = grep_search(&files, &parsed, &plain_opts());
// Only the text file should be searched, not the binary one
assert_eq!(result.files.len(), 1);
@@ -256,7 +276,8 @@ fn plain_text_max_matches_per_file() {
let mut opts = plain_opts();
opts.max_matches_per_file = 5;
let result = grep_search(&files, "match_target", None, &opts);
let parsed = parse_grep_query("match_target");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(
result.matches.len(),
@@ -277,19 +298,31 @@ fn plain_text_page_limit() {
let mut opts = plain_opts();
opts.page_limit = 10;
let result = grep_search(&files, "target", None, &opts);
let parsed = parse_grep_query("target");
let result = grep_search(&files, &parsed, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
// max_matches_per_file (200) from a single file.
assert!(
result.matches.len() <= 10,
"should respect page_limit: got {}",
result.matches.len() >= opts.page_limit,
"should return at least page_limit matches: got {}",
result.matches.len()
);
assert!(
result.matches.len() <= opts.max_matches_per_file,
"should never exceed max_matches_per_file: got {}",
result.matches.len()
);
// Single file with 100 lines all matching — all should be returned.
assert_eq!(result.matches.len(), 100, "all 100 lines must be returned");
}
#[test]
fn plain_text_file_offset_pagination() {
let tmp = TempDir::new().unwrap();
// Create many files so file-based pagination works
// Create many files (1 match per file) so file-based pagination exercises
// offset tracking across files with and without matches.
let mut files = Vec::new();
for i in 0..20 {
files.push(create_file(
@@ -302,14 +335,52 @@ fn plain_text_file_offset_pagination() {
let mut opts = plain_opts();
opts.page_limit = 5;
let result1 = grep_search(&files, "unique_token", None, &opts);
assert!(result1.matches.len() > 0);
// Collect ALL matches across all pages and verify no duplicates and full coverage.
let mut all_line_texts: Vec<String> = Vec::new();
let mut pages = 0;
let max_pages = 20; // safety limit
if result1.next_file_offset > 0 {
opts.file_offset = result1.next_file_offset;
let result2 = grep_search(&files, "unique_token", None, &opts);
assert!(result2.matches.len() > 0, "second page should have results");
loop {
let parsed = parse_grep_query("unique_token");
let result = grep_search(&files, &parsed, &opts);
for m in &result.matches {
let text = m.line_content.trim().to_string();
assert!(
!all_line_texts.contains(&text),
"duplicate match across pages: '{}'",
text
);
all_line_texts.push(text);
}
pages += 1;
assert!(pages <= max_pages, "pagination did not terminate");
if result.next_file_offset == 0 {
break;
}
// Offset must strictly advance
assert!(
result.next_file_offset > opts.file_offset,
"next_file_offset ({}) did not advance past current ({})",
result.next_file_offset,
opts.file_offset
);
opts.file_offset = result.next_file_offset;
}
assert_eq!(
all_line_texts.len(),
20,
"pagination should find all 20 matches across all pages, got {}",
all_line_texts.len()
);
assert!(
pages > 1,
"should require multiple pages with page_limit=5 and 20 files"
);
}
#[test]
@@ -321,7 +392,8 @@ fn plain_text_line_numbers_are_correct() {
"line one\nline two\nline three\nline four\n",
)];
let result = grep_search(&files, "line", None, &plain_opts());
let parsed = parse_grep_query("line");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 4);
assert_eq!(result.matches[0].line_number, 1);
@@ -340,7 +412,8 @@ fn plain_text_max_file_size_filter() {
let mut opts = plain_opts();
opts.max_file_size = 100; // Only allow files up to 100 bytes
let result = grep_search(&files, "match_me", None, &opts);
let parsed = parse_grep_query("match_me");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(result.matches.len(), 0, "large file should be filtered out");
assert_eq!(result.filtered_file_count, 0);
@@ -357,7 +430,8 @@ fn regex_basic_pattern() {
"foo123\nbar456\nbaz789\nfoo_bar\n",
)];
let result = grep_search(&files, "foo\\d+", None, &regex_opts());
let parsed = parse_grep_query("foo\\d+");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -370,7 +444,8 @@ fn regex_capture_group_matching() {
let files = vec![create_file(tmp.path(), "a.txt", "foobar\nfoobaz\nfoo123\n")];
// Use a capturing group (not lookahead, which regex crate doesn't support)
let result = grep_search(&files, "foo(bar|baz)", None, &regex_opts());
let parsed = parse_grep_query("foo(bar|baz)");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 2);
let contents: Vec<&str> = result
@@ -392,7 +467,8 @@ fn regex_dot_matches_any_char() {
)];
// In regex mode, . matches any character, so v1.0 matches v1.0, v1x0, v1-0, and v100
let result = grep_search(&files, "v1.0", None, &regex_opts());
let parsed = parse_grep_query("v1.0");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -410,7 +486,8 @@ fn regex_alternation() {
"apple\nbanana\ncherry\napricot\n",
)];
let result = grep_search(&files, "apple|cherry", None, &regex_opts());
let parsed = parse_grep_query("apple|cherry");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 2);
let lines: Vec<u64> = result.matches.iter().map(|m| m.line_number).collect();
@@ -427,7 +504,8 @@ fn regex_character_class() {
"cat\ncut\ncot\ncit\ncxt\n",
)];
let result = grep_search(&files, "c[aou]t", None, &regex_opts());
let parsed = parse_grep_query("c[aou]t");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 3);
let contents: Vec<&str> = result
@@ -449,7 +527,8 @@ fn regex_quantifiers() {
"fo\nfoo\nfooo\nfoooo\nbar\n",
)];
let result = grep_search(&files, "fo{2,3}", None, &regex_opts());
let parsed = parse_grep_query("fo{2,3}");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 3, "should match foo, fooo, foooo");
}
@@ -463,18 +542,42 @@ fn regex_anchors() {
"start of line\nmiddle start end\nend of line\n",
)];
let result = grep_search(&files, "^start", None, &regex_opts());
let parsed = parse_grep_query("^start");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
}
#[test]
fn regex_anchors_multiword() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(
tmp.path(),
"test.c",
"int ff_function(void);\nstatic int ff_other(void);\nint main(void);\nint ff_another(void);\n",
)];
// ^int ff_ should match lines starting with "int ff_"
let parsed = parse_grep_query("^int ff_");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
2,
"should match 2 lines starting with 'int ff_'"
);
assert!(result.matches[0].line_content.contains("ff_function"));
assert!(result.matches[1].line_content.contains("ff_another"));
}
#[test]
fn regex_highlight_offsets_variable_length() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "aab aaab aaaab\n")];
let result = grep_search(&files, "a+b", None, &regex_opts());
let parsed = parse_grep_query("a+b");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -496,7 +599,8 @@ fn regex_invalid_pattern_falls_back_to_literal() {
)];
// Invalid regex: unmatched group — should fall back to literal search
let result = grep_search(&files, "name(", None, &regex_opts());
let parsed = parse_grep_query("name(");
let result = grep_search(&files, &parsed, &regex_opts());
// Fallback to literal: finds "name(" in "call name(arg)"
assert_eq!(
@@ -511,7 +615,8 @@ fn regex_invalid_pattern_falls_back_to_literal() {
assert!(result.matches[0].line_content.contains("name("));
// A pattern that doesn't exist anywhere — still falls back but finds nothing
let result2 = grep_search(&files, "zzz(", None, &regex_opts());
let parsed2 = parse_grep_query("zzz(");
let result2 = grep_search(&files, &parsed2, &regex_opts());
assert_eq!(result2.matches.len(), 0);
assert!(result2.regex_fallback_error.is_some());
}
@@ -526,11 +631,13 @@ fn regex_smart_case() {
)];
// Lowercase query → case-insensitive
let result_lower = grep_search(&files, "foo", None, &regex_opts());
let parsed_lower = parse_grep_query("foo");
let result_lower = grep_search(&files, &parsed_lower, &regex_opts());
assert_eq!(result_lower.matches.len(), 3);
// Query with uppercase → case-sensitive
let result_upper = grep_search(&files, "Foo", None, &regex_opts());
let parsed_upper = parse_grep_query("Foo");
let result_upper = grep_search(&files, &parsed_upper, &regex_opts());
assert_eq!(result_upper.matches.len(), 1);
}
@@ -551,7 +658,8 @@ fn regex_across_multiple_files() {
create_file(tmp.path(), "readme.md", "# Title\nSome text\n"),
];
let result = grep_search(&files, "fn \\w+\\(\\)", None, &regex_opts());
let parsed = parse_grep_query("fn \\w+\\(\\)");
let result = grep_search(&files, &parsed, &regex_opts());
// Should match: fn main(), fn helper(), fn test_one(), fn test_two()
assert_eq!(result.matches.len(), 4);
@@ -569,8 +677,9 @@ fn plain_text_and_regex_agree_on_simple_literal() {
"hello world\ngoodbye world\nhello again\n",
)];
let plain_result = grep_search(&files, "hello", None, &plain_opts());
let regex_result = grep_search(&files, "hello", None, &regex_opts());
let parsed = parse_grep_query("hello");
let plain_result = grep_search(&files, &parsed, &plain_opts());
let regex_result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(plain_result.matches.len(), regex_result.matches.len());
for (p, r) in plain_result.matches.iter().zip(regex_result.matches.iter()) {
@@ -589,8 +698,10 @@ fn plain_text_escapes_what_regex_does_not() {
)];
// "$100" — in plain text, $ is literal; in regex, $ is anchor
let plain_result = grep_search(&files, "$100", None, &plain_opts());
let regex_result = grep_search(&files, "\\$100", None, &regex_opts());
let parsed_plain = parse_grep_query("$100");
let plain_result = grep_search(&files, &parsed_plain, &plain_opts());
let parsed_regex = parse_grep_query("\\$100");
let regex_result = grep_search(&files, &parsed_regex, &regex_opts());
// Plain text should find "$100" literally
assert_eq!(plain_result.matches.len(), 1);
@@ -612,7 +723,7 @@ fn grep_with_extension_constraint() {
];
let parsed = parse_grep_query("use std *.rs");
let result = grep_search(&files, "use std *.rs", parsed, &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
// Should only search .rs files
for file in &result.files {
@@ -639,7 +750,8 @@ fn plain_text_bracket_is_literal() {
"let x = arr[0];\nlet y = arr[1];\nlet z = something;\n",
)];
let result = grep_search(&files, "arr[0]", None, &plain_opts());
let parsed = parse_grep_query("arr[0]");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -661,7 +773,7 @@ fn grep_backslash_escapes_extension_filter() {
// Without escape: "*.rs" is an extension filter, so only .rs files are searched
let parsed = parse_grep_query("pattern *.rs");
let result_filter = grep_search(&files, "pattern *.rs", parsed, &plain_opts());
let result_filter = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result_filter.files.len(),
1,
@@ -670,7 +782,7 @@ fn grep_backslash_escapes_extension_filter() {
// With escape: "\*.rs" is literal text, both files are searched
let parsed_escaped = parse_grep_query("\\*.rs");
let result_literal = grep_search(&files, "\\*.rs", parsed_escaped, &plain_opts());
let result_literal = grep_search(&files, &parsed_escaped, &plain_opts());
assert_eq!(
result_literal.matches.len(),
2,
@@ -687,7 +799,8 @@ fn grep_backslash_escapes_path_segment() {
];
// With escape: "\\/src/" is literal text, not a path constraint
let result = grep_search(&files, "\\/src/", None, &plain_opts());
let parsed = parse_grep_query("\\/src/");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
2,
@@ -705,7 +818,8 @@ fn grep_backslash_escapes_negation() {
)];
// With escape: "\\!test" is literal text "!test"
let result = grep_search(&files, "\\!test", None, &plain_opts());
let parsed = parse_grep_query("\\!test");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert!(result.matches[0].line_content.contains("!test"));
}
@@ -720,7 +834,7 @@ fn grep_with_path_constraint() {
];
let parsed = parse_grep_query("target_text /src/");
let result = grep_search(&files, "target_text /src/", parsed, &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert!(result.files[0].relative_path.starts_with("src/"));
@@ -739,7 +853,7 @@ fn grep_with_negated_extension_constraint() {
let query = "target_text !*.rs";
let parsed = parse_grep_query(query);
let result = grep_search(&files, query, parsed, &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -765,7 +879,7 @@ fn grep_with_negated_path_constraint() {
let query = "target_text !/src/";
let parsed = parse_grep_query(query);
let result = grep_search(&files, query, parsed, &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -791,7 +905,7 @@ fn grep_with_negated_text_constraint() {
let query = "target_text !test";
let parsed = parse_grep_query(query);
let result = grep_search(&files, query, parsed, &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
// "tests/helper.rs" contains "test" in path, should be excluded
assert_eq!(
@@ -821,7 +935,8 @@ fn grep_empty_file_is_skipped() {
let text_file = create_file(tmp.path(), "text.txt", "findme\n");
let files = vec![empty_file, text_file];
let result = grep_search(&files, "findme", None, &plain_opts());
let parsed = parse_grep_query("findme");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
}
@@ -831,7 +946,8 @@ fn grep_single_line_no_trailing_newline() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "no newline at end")];
let result = grep_search(&files, "no newline", None, &plain_opts());
let parsed = parse_grep_query("no newline");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -846,11 +962,13 @@ fn grep_unicode_content() {
"日本語テスト\nrégulière\nñoño\n",
)];
let result = grep_search(&files, "régulière", None, &plain_opts());
let parsed = parse_grep_query("régulière");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 2);
let result2 = grep_search(&files, "ñoño", None, &plain_opts());
let parsed2 = parse_grep_query("ñoño");
let result2 = grep_search(&files, &parsed2, &plain_opts());
assert_eq!(result2.matches.len(), 1);
assert_eq!(result2.matches[0].line_number, 3);
}
@@ -861,7 +979,8 @@ fn grep_long_line_is_truncated() {
let long_line = "x".repeat(1000) + "NEEDLE" + &"y".repeat(1000);
let files = vec![create_file(tmp.path(), "long.txt", &long_line)];
let result = grep_search(&files, "NEEDLE", None, &plain_opts());
let parsed = parse_grep_query("NEEDLE");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
// The line_content should be truncated to MAX_LINE_DISPLAY_LEN (512)
@@ -881,7 +1000,8 @@ fn regex_word_boundary() {
"foo\nfoobar\nbarfoo\nfoo_baz\n",
)];
let result = grep_search(&files, "\\bfoo\\b", None, &regex_opts());
let parsed = parse_grep_query("\\bfoo\\b");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -900,7 +1020,8 @@ fn plain_text_question_mark_is_literal() {
"what is this?\nhow does it work?\nno question here\nwhat?\n",
)];
let result = grep_search(&files, "?", None, &plain_opts());
let parsed = parse_grep_query("?");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -918,7 +1039,8 @@ fn plain_text_query_with_question_mark_in_word() {
"let x = foo?;\nlet y = bar.baz();\nfoo?.unwrap()\n",
)];
let result = grep_search(&files, "foo?", None, &plain_opts());
let parsed = parse_grep_query("foo?");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -933,7 +1055,8 @@ fn regex_question_mark_is_quantifier() {
let files = vec![create_file(tmp.path(), "a.txt", "color\ncolour\ncolouur\n")];
// In regex mode, ? means "zero or one of preceding"
let result = grep_search(&files, "colou?r", None, &regex_opts());
let parsed = parse_grep_query("colou?r");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -953,7 +1076,8 @@ fn fuzzy_finds_exact_substring() {
"hello world\ngoodbye world\nhello again\n",
)];
let result = grep_search(&files, "hello", None, &fuzzy_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(
result.matches.len(),
@@ -974,10 +1098,11 @@ fn fuzzy_finds_scattered_characters() {
)];
// "mutex" should fuzzy match "mutex_lock" (contiguous prefix)
let result = grep_search(&files, "mutex", None, &fuzzy_opts());
let parsed = parse_grep_query("mutex");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert!(
result.matches.len() >= 1,
!result.matches.is_empty(),
"fuzzy should find 'mutex' in 'mutex_lock'"
);
assert!(result.matches[0].line_content.contains("mutex_lock"));
@@ -988,7 +1113,8 @@ fn fuzzy_highlight_offsets_correct() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "hello world\n")];
let result = grep_search(&files, "hell", None, &fuzzy_opts());
let parsed = parse_grep_query("hell");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -1012,11 +1138,12 @@ fn fuzzy_unicode_char_indices() {
// Use "guli" which is a contiguous ASCII substring within "régulière"
// (the chars g-u-l-i appear contiguously between the two accented chars)
let result = grep_search(&files, "guli", None, &fuzzy_opts());
let parsed = parse_grep_query("guli");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should fuzzy match "régulière" (with multi-byte é and è)
// This tests that character-to-byte offset conversion works with UTF-8
assert!(result.matches.len() >= 1);
assert!(!result.matches.is_empty());
assert!(result.matches[0].line_content.contains("régulière"));
}
@@ -1025,7 +1152,8 @@ fn fuzzy_empty_query_returns_empty() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "some content\n")];
let result = grep_search(&files, "", None, &fuzzy_opts());
let parsed = parse_grep_query("");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Empty query returns git-modified files, not fuzzy matches
assert_eq!(result.matches.len(), 0);
@@ -1041,7 +1169,7 @@ fn fuzzy_with_extension_constraint() {
];
let parsed = parse_grep_query("use std *.rs");
let result = grep_search(&files, "use std *.rs", parsed, &fuzzy_opts());
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should only search .rs files
for file in &result.files {
@@ -1064,14 +1192,30 @@ fn fuzzy_respects_page_limit() {
let mut opts = fuzzy_opts();
opts.page_limit = 10;
opts.max_matches_per_file = 50;
let result = grep_search(&files, "target", None, &opts);
let parsed = parse_grep_query("target");
let result = grep_search(&files, &parsed, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
// max_matches_per_file (200) from a single file.
assert!(
result.matches.len() <= 10,
"should respect page_limit: got {}",
result.matches.len() >= opts.page_limit,
"should return at least page_limit matches: got {}",
result.matches.len()
);
assert!(
result.matches.len() <= opts.max_matches_per_file,
"should never exceed max_matches_per_file: got {}",
result.matches.len()
);
assert_eq!(
result.matches.len(),
opts.max_matches_per_file,
"all limit of lines must be returned"
);
}
#[test]
@@ -1086,7 +1230,8 @@ fn fuzzy_respects_max_matches_per_file() {
let mut opts = fuzzy_opts();
opts.max_matches_per_file = 5;
let result = grep_search(&files, "match", None, &opts);
let parsed = parse_grep_query("match");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(
result.matches.len(),
@@ -1106,7 +1251,8 @@ fn fuzzy_filters_low_quality_matches() {
// Search for "abc" - should match "abc_def_ghi" and "abcdefghij" with high scores,
// but NOT "xyz" (no relation) or "mutex_lock" (only weak letter overlap)
let result = grep_search(&files, "abc", None, &fuzzy_opts());
let parsed = parse_grep_query("abc");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should only get high-quality matches
assert!(
@@ -1135,7 +1281,8 @@ fn fuzzy_exact_match_always_passes() {
)];
// Exact matches should always pass regardless of score threshold
let result = grep_search(&files, "exact", None, &fuzzy_opts());
let parsed = parse_grep_query("exact");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(
result.matches.len(),
@@ -1154,7 +1301,8 @@ fn fuzzy_score_is_captured() {
"hello world\ngoodbye world\n",
)];
let result = grep_search(&files, "hello", None, &fuzzy_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -1175,7 +1323,8 @@ fn fuzzy_score_is_none_in_plain_mode() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "test.txt", "hello world\n")];
let result = grep_search(&files, "hello", None, &plain_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "fff-mcp"
version = "0.1.0"
edition = "2024"
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
license = "MIT"
[[bin]]
name = "fff-mcp"
path = "src/main.rs"
[features]
default = ["zlob"]
zlob = ["fff-core/zlob"]
[dependencies]
fff-core = { path = "../fff-core", default-features = false }
fff-query-parser = { path = "../fff-query-parser", default-features = false }
mimalloc = { workspace = true }
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
schemars = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tracing = { workspace = true }
git2 = { workspace = true }
clap = { version = "4", features = ["derive"] }
+15
View File
@@ -0,0 +1,15 @@
fn main() {
// Embed the git commit hash at build time for update checking.
let hash = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=FFF_GIT_HASH={}", hash);
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/refs/");
}
+52
View File
@@ -0,0 +1,52 @@
//! Cursor store for grep pagination.
//!
//! Maintains an in-memory map of opaque cursor IDs to file offsets.
//! Cursors are evicted LRU-style when the store exceeds capacity.
use std::collections::{HashMap, VecDeque};
const MAX_CURSORS: usize = 20;
/// Stores cursor state for paginated grep results.
pub struct CursorStore {
counter: u64,
/// Map from cursor ID string → file offset for next page.
cursors: HashMap<String, usize>,
/// Insertion order for LRU eviction.
insertion_order: VecDeque<String>,
}
impl CursorStore {
pub fn new() -> Self {
Self {
counter: 0,
cursors: HashMap::new(),
insertion_order: VecDeque::new(),
}
}
/// Store a cursor and return its opaque ID string.
pub fn store(&mut self, file_offset: usize) -> String {
self.counter = self.counter.wrapping_add(1);
let id = self.counter.to_string();
self.cursors.insert(id.clone(), file_offset);
self.insertion_order.push_back(id.clone());
// Evict oldest cursors
while self.cursors.len() > MAX_CURSORS {
if let Some(oldest) = self.insertion_order.pop_front() {
self.cursors.remove(&oldest);
} else {
break;
}
}
id
}
/// Retrieve the file offset for a cursor ID.
pub fn get(&self, id: &str) -> Option<usize> {
self.cursors.get(id).copied()
}
}
+294
View File
@@ -0,0 +1,294 @@
//! FFF MCP Server — high-performance file finder for AI code assistants.
//!
//! Drop-in replacement for AI code assistant file search tools (Glob/Grep).
//! Provides frecency-ranked, fuzzy-matched, git-aware file finding and
//! code search via the Model Context Protocol (MCP).
//!
//! Uses `fff-core` directly (zero FFI overhead) for all search operations.
mod cursor;
mod output;
mod server;
mod update_check;
use std::sync::{Arc, RwLock};
use clap::Parser;
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::{FFFMode, SharedFrecency, SharedPicker};
use git2::Repository;
use mimalloc::MiMalloc;
use rmcp::{ServiceExt, transport::stdio};
use server::FffServer;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
pub const MCP_INSTRUCTIONS: &str = concat!(
"FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n",
"\n",
"## Which Tool Should I Use?\n",
"\n",
"- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n",
"- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n",
"- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n",
"\n",
"## Core Rules\n",
"\n",
"### 1. Search BARE IDENTIFIERS only\n",
"Grep matches single lines. Search for ONE identifier per query:\n",
" + 'InProgressQuote' -> finds definition + all usages\n",
" + 'ActorAuth' -> finds enum, struct, all call sites\n",
" x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n",
" x 'ctx.data::<ActorAuth>' -> code syntax, too specific, 0 results\n",
" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n",
" x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n",
"\n",
"### 2. NEVER use regex unless you truly need alternation\n",
"Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n",
"If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n",
"\n",
"### 3. Stop searching after 2 greps -- READ the code\n",
"After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n",
"Do NOT keep grepping with variations. More greps != better understanding.\n",
"\n",
"### 4. Use multi_grep for multiple identifiers\n",
"When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n",
" + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n",
" x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n",
"\n",
"## Workflow\n",
"\n",
"**Have a specific name?** -> grep the bare identifier.\n",
"**Need multiple name variants?** -> multi_grep with all variants in one call.\n",
"**Exploring a topic / finding files?** -> find_files.\n",
"**Got results?** -> Read the top file. Don't grep again.\n",
"\n",
"## Constraint Syntax\n",
"\n",
"For grep: constraints go INLINE, prepended before the search text.\n",
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
"\n",
"Constraints MUST match one of these formats:\n",
" Extension: '*.rs', '*.{ts,tsx}'\n",
" Directory: 'src/', 'quotes/'\n",
" Filename: 'schema.rs', 'src/main.rs'\n",
" Exclude: '!test/', '!*.spec.ts'\n",
"\n",
"! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n",
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
"\n",
"Prefer broad constraints:\n",
" + '*.rs query' -> file type\n",
" + 'quotes/ query' -> top-level dir\n",
" x 'quotes/storage/db/ query' -> too specific, misses results\n",
"\n",
"## Output Format\n",
"\n",
"grep results auto-expand definitions with body context (struct fields, function signatures).\n",
"This often provides enough information WITHOUT a follow-up Read call.\n",
"Lines marked with | are definition body context. [def] marks definition files.\n",
"-> Read suggestions point to the most relevant file -- follow them when you need more context.\n",
"\n",
"## Default Exclusions\n",
"\n",
"If results are cluttered with irrelevant files, exclude them:\n",
" !tests/ - exclude tests directory\n",
" !*.spec.ts - exclude test files\n",
" !generated/ - exclude generated code",
);
/// FFF MCP Server — high-performance file finder for AI code assistants.
#[derive(Parser)]
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
struct Args {
/// Base directory to index. Defaults to the current working directory.
#[arg(value_name = "PATH")]
base_path: Option<String>,
/// Path to the frecency database.
#[arg(long = "frecency-db")]
frecency_db_path: Option<String>,
/// Path to the query history database.
#[arg(long = "history-db")]
#[allow(dead_code)]
history_db_path: Option<String>,
/// Path to the log file.
#[arg(long = "log-file")]
log_file: Option<String>,
/// Log level (e.g. trace, debug, info, warn, error).
#[arg(long = "log-level")]
log_level: Option<String>,
/// Disable automatic update checks on startup.
#[arg(long = "no-update-check")]
no_update_check: bool,
}
/// Resolve default paths for frecency db, history db, and log file.
/// Shares Neovim's standard data locations when they exist so the MCP
/// server and fff.nvim plugin use the same databases.
fn resolve_defaults(args: &mut Args) {
let home = dirs_home();
let is_windows = cfg!(target_os = "windows");
let nvim_cache_dir = if is_windows {
format!("{}\\AppData\\Local\\nvim-data", home)
} else {
format!("{}/.cache/nvim", home)
};
let nvim_data_dir = if is_windows {
format!("{}\\AppData\\Local\\nvim-data", home)
} else {
format!("{}/.local/share/nvim", home)
};
let use_nvim_paths = std::path::Path::new(&nvim_cache_dir).exists()
|| std::path::Path::new(&nvim_data_dir).exists();
if args.frecency_db_path.is_none() {
args.frecency_db_path = Some(if use_nvim_paths {
format!("{}/fff_nvim", nvim_cache_dir)
} else {
format!("{}/.fff/frecency.mdb", home)
});
}
if args.history_db_path.is_none() {
args.history_db_path = Some(if use_nvim_paths {
format!("{}/fff_queries", nvim_data_dir)
} else {
format!("{}/.fff/history.mdb", home)
});
}
// Ensure parent directories exist for database paths
for path in [&args.frecency_db_path, &args.history_db_path]
.into_iter()
.flatten()
{
if let Some(parent) = std::path::Path::new(path).parent() {
let _ = std::fs::create_dir_all(parent);
}
}
if args.log_file.is_none() {
args.log_file = Some(if is_windows {
format!("{}\\AppData\\Local\\fff_mcp.log", home)
} else {
format!("{}/.cache/fff_mcp.log", home)
});
}
}
fn dirs_home() -> String {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = Args::parse();
resolve_defaults(&mut args);
let log_file = args.log_file.as_deref().unwrap_or("");
if let Err(e) = fff_core::log::init_tracing(log_file, args.log_level.as_deref()) {
eprintln!("Warning: Failed to init tracing: {}", e);
}
let base_path = args.base_path.unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
if Repository::discover(&base_path).is_err() {
tracing::error!("MCP server must be run within a Git repository");
return Err(format!("Not a Git repository: {}", base_path).into());
}
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
match FrecencyTracker::new(&frecency_db_path, false) {
Ok(tracker) => {
if let Ok(mut guard) = shared_frecency.write() {
*guard = Some(tracker);
}
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
}
Err(e) => {
eprintln!("Warning: Failed to init frecency db: {}", e);
}
}
// Initialize file picker (spawns background scan + watcher)
FilePicker::new_with_shared_state(
base_path,
true, // warmup_mmap_cache
FFFMode::Ai,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)
.map_err(|e| format!("Failed to init file picker: {}", e))?;
if !args.no_update_check {
update_check::spawn_update_check();
}
// Create and start the MCP server
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
// Wait for initial scan in background — don't block server startup
let picker_clone_for_scan = Arc::clone(&shared_picker);
tokio::task::spawn_blocking(move || {
let start = std::time::Instant::now();
loop {
let is_scanning = picker_clone_for_scan
.read()
.ok()
.and_then(|g| g.as_ref().map(|p| p.is_scan_active()))
.unwrap_or(true);
if !is_scanning {
tracing::info!("Initial scan completed in {:?}", start.elapsed());
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
});
let service = server
.serve(stdio())
.await
.map_err(|e| format!("Failed to start MCP server: {}", e))?;
let picker_for_shutdown = shared_picker.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
if let Ok(mut guard) = picker_for_shutdown.write()
&& let Some(ref mut picker) = *guard
{
picker.stop_background_monitor();
}
std::process::exit(0);
});
service.waiting().await?;
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
picker.stop_background_monitor();
}
Ok(())
}
+596
View File
@@ -0,0 +1,596 @@
//! Output formatting for MCP grep/search results.
//!
//! Port of `packages/fff-mcp/src/output.ts` — token-efficient formatting
//! with definition auto-expansion, frecency/git annotations, and Read suggestions.
use fff_core::GrepMatch;
use fff_core::git::format_git_status_opt;
use fff_core::grep::is_import_line;
use fff_core::types::FileItem;
use crate::cursor::CursorStore;
/// Frecency score → single-token word. `None` for low-scoring files.
fn frecency_word(score: i64) -> Option<&'static str> {
if score >= 100 {
Some("hot")
} else if score >= 50 {
Some("warm")
} else if score >= 10 {
Some("frequent")
} else {
None
}
}
/// Build " - hot git:modified" style suffix. Empty when nothing to report.
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i64) -> String {
match (
frecency_word(frecency_score),
format_git_status_opt(git_status),
) {
(Some(f), Some(g)) => format!(" - {f} git:{g}"),
(Some(f), None) => format!(" - {f}"),
(None, Some(g)) => format!(" git:{g}"),
(None, None) => String::new(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Content,
FilesWithMatches,
Count,
Usage,
}
impl OutputMode {
pub fn new(s: Option<&str>) -> Self {
match s {
Some("files_with_matches") => Self::FilesWithMatches,
Some("count") => Self::Count,
Some("usage") => Self::Usage,
_ => Self::Content,
}
}
}
const LARGE_FILE_BYTES: u64 = 20_000;
/// Tag for large files — nudges model to use offset/limit when reading.
fn size_tag(bytes: u64) -> String {
if bytes < LARGE_FILE_BYTES {
String::new()
} else {
let kb = (bytes + 512) / 1024; // round
format!(" ({}KB - use offset to read relevant section)", kb)
}
}
const MAX_PREVIEW: usize = 120;
const MAX_LINE_LEN: usize = 180;
/// Max context lines to show when auto-expanding the first definition
const MAX_DEF_EXPAND_FIRST: usize = 8;
/// Max context lines for subsequent definitions
const MAX_DEF_EXPAND: usize = 5;
/// Max context lines for non-definition first match in small result sets
const MAX_FIRST_MATCH_EXPAND: usize = 8;
fn trauncate_line_for_ai(
line: &str,
match_ranges: Option<&[(u32, u32)]>,
max_len: usize,
) -> String {
// Strip leading/trailing whitespace to save tokens — the LLM has file:line for location.
let trimmed = line.trim();
if trimmed.is_empty() {
return String::new();
}
let strip_offset = line.len() - line.trim_start().len();
if trimmed.len() <= max_len {
return trimmed.to_string();
}
// Adjust match ranges for the stripped leading whitespace
let adjusted: Vec<(u32, u32)>;
let ranges = match match_ranges {
Some(r) if strip_offset > 0 => {
let off = strip_offset as u32;
adjusted = r
.iter()
.map(|&(s, e)| (s.saturating_sub(off), e.saturating_sub(off)))
.collect();
Some(adjusted.as_slice())
}
other => other,
};
// Use first match range to center the window
if let Some(ranges) = ranges
&& let Some(&(match_start, match_end)) = ranges.first()
{
let match_start = match_start as usize;
let match_end = match_end as usize;
let match_len = match_end.saturating_sub(match_start);
let budget = max_len.saturating_sub(match_len);
let before = budget / 3;
let after = budget - before;
let win_start = match_start.saturating_sub(before);
let win_end = (match_end + after).min(trimmed.len());
// Clamp to char boundaries
let win_start = floor_char_boundary(trimmed, win_start);
let win_end = ceil_char_boundary(trimmed, win_end);
let mut result = trimmed[win_start..win_end].to_string();
if win_start > 0 {
result.insert(0, '…');
}
if win_end < trimmed.len() {
result.push('…');
}
return result;
}
// No match ranges — truncate from start
let end = ceil_char_boundary(trimmed, max_len);
format!("{}", &trimmed[..end])
}
/// Floor to a valid char boundary
fn floor_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Ceil to a valid char boundary
fn ceil_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Collected file metadata for the first match per file.
struct FileMeta<'a> {
file: &'a FileItem,
line_number: u64,
line_content: String,
is_definition: bool,
match_ranges: Vec<(u32, u32)>,
context_after: Vec<String>,
}
/// Parameters for [`format_grep_results`].
///
/// Groups the read-only inputs so callers don't juggle 10 positional args.
pub struct GrepFormatter<'a> {
pub matches: &'a [GrepMatch],
pub files: &'a [&'a FileItem],
pub total_matched: usize,
pub next_file_offset: usize,
pub regex_fallback_error: Option<&'a str>,
pub output_mode: OutputMode,
pub max_results: usize,
pub show_context: bool,
pub auto_expand_defs: bool,
}
impl GrepFormatter<'_> {
pub fn format(&self, cursor_store: &mut CursorStore) -> String {
let GrepFormatter {
matches,
files,
total_matched,
next_file_offset,
regex_fallback_error,
output_mode,
max_results,
show_context,
auto_expand_defs,
} = *self;
let items = if matches.len() > max_results {
&matches[..max_results]
} else {
matches
};
if output_mode == OutputMode::FilesWithMatches {
return format_files_with_matches(
items,
files,
next_file_offset,
auto_expand_defs,
cursor_store,
);
}
if output_mode == OutputMode::Count {
return format_count(items, files, next_file_offset, cursor_store);
}
// output_mode == usage
let mut lines: Vec<String> = Vec::new();
let unique_files = {
let mut seen = std::collections::HashSet::new();
for m in items {
seen.insert(m.file_index);
}
seen.len()
};
let max_output_chars: usize = if output_mode == OutputMode::Usage || unique_files <= 3 {
5000
} else if unique_files <= 8 {
3500
} else {
2500
};
if let Some(err) = regex_fallback_error {
lines.push(format!("! regex failed: {}, using literal match", err));
}
// File overview: collect first match per file
let file_preview = collect_file_preview(items, files);
let mut content_def_file = "";
let mut content_first_file = "";
for fm in &file_preview {
if content_first_file.is_empty() {
content_first_file = &fm.file.relative_path;
}
if content_def_file.is_empty() && fm.is_definition {
content_def_file = &fm.file.relative_path;
}
}
let content_suggest = if !content_def_file.is_empty() {
content_def_file
} else {
content_first_file
};
if !content_suggest.is_empty() {
let file_count = file_preview.len();
if file_count == 1 {
lines.push(format!("→ Read {} (only match)", content_suggest));
} else if !content_def_file.is_empty() {
lines.push(format!("→ Read {} [def]", content_suggest));
} else if file_count <= 3 {
lines.push(format!("→ Read {} (best match)", content_suggest));
}
}
if total_matched > items.len() {
lines.push(format!("{}/{} matches shown", items.len(), total_matched));
}
// Track which files already had a definition expanded
let mut def_expanded_files = std::collections::HashSet::new();
// Detailed content (subject to budget)
let mut char_count = 0usize;
let mut shown_count = 0usize;
let mut current_file = "";
// Reorder: definitions first, then usages, then imports (when auto-expanding)
let sorted_items: Vec<usize> = if auto_expand_defs {
let mut indices: Vec<usize> = (0..items.len()).collect();
indices.sort_unstable_by_key(|&i| {
if items[i].is_definition {
0
} else if is_import_line(&items[i].line_content) {
2
} else {
1
}
});
indices
} else {
(0..items.len()).collect()
};
for &idx in &sorted_items {
let m = &items[idx];
let file = files[m.file_index];
let mut match_lines: Vec<String> = Vec::new();
if file.relative_path.as_str() != current_file {
current_file = &file.relative_path;
match_lines.push(current_file.to_string());
}
// Skip import-only lines when we already have definitions
if auto_expand_defs && is_import_line(&m.line_content) && !def_expanded_files.is_empty()
{
continue;
}
// Context before (only when explicitly requested)
if show_context && !m.context_before.is_empty() {
let start_line = m.line_number.saturating_sub(m.context_before.len() as u64);
for (i, ctx) in m.context_before.iter().enumerate() {
match_lines.push(format!(
" {}-{}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
));
}
}
// Match line
match_lines.push(format!(
" {}: {}",
m.line_number,
trauncate_line_for_ai(
&m.line_content,
Some(m.match_byte_offsets.as_ref()),
MAX_LINE_LEN
)
));
// Context after (only when explicitly requested via context parameter)
if show_context && !m.context_after.is_empty() {
let start_line = m.line_number + 1;
for (i, ctx) in m.context_after.iter().enumerate() {
match_lines.push(format!(
" {}-{}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
));
}
match_lines.push("--".to_string());
}
// Auto-expand definitions with body context
if auto_expand_defs
&& !show_context
&& m.is_definition
&& !m.context_after.is_empty()
&& !def_expanded_files.contains(file.relative_path.as_str())
{
let expand_limit = if def_expanded_files.is_empty() {
MAX_DEF_EXPAND_FIRST
} else {
MAX_DEF_EXPAND
};
def_expanded_files.insert(file.relative_path.as_str());
let start_line = m.line_number + 1;
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
if ctx.trim().is_empty() {
break;
}
match_lines.push(format!(
" {}| {}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
));
}
}
let chunk = match_lines.join("\n");
if char_count + chunk.len() > max_output_chars && shown_count > 0 {
break;
}
char_count += chunk.len();
lines.push(chunk);
shown_count += 1;
}
if next_file_offset > 0 {
let cursor_id = cursor_store.store(next_file_offset);
lines.push(format!("\ncursor: {}", cursor_id));
}
lines.join("\n")
}
}
fn format_files_with_matches(
items: &[GrepMatch],
files: &[&FileItem],
next_file_offset: usize,
auto_expand_defs: bool,
cursor_store: &mut CursorStore,
) -> String {
let file_map = collect_file_preview(items, files);
let mut lines: Vec<String> = Vec::new();
let file_count = file_map.len();
// Find best Read target
let mut first_def_file = "";
let mut first_file = "";
for fm in &file_map {
if first_file.is_empty() {
first_file = &fm.file.relative_path;
}
if first_def_file.is_empty() && fm.is_definition {
first_def_file = &fm.file.relative_path;
}
}
let suggest_path = if !first_def_file.is_empty() {
first_def_file
} else {
first_file
};
if !suggest_path.is_empty() {
if file_count == 1 {
lines.push(format!(
"→ Read {} (only match — no need to search further)",
suggest_path
));
} else if !first_def_file.is_empty() && file_count <= 5 {
lines.push(format!("→ Read {} (definition found)", suggest_path));
} else if !first_def_file.is_empty() {
lines.push(format!("→ Read {} (definition)", suggest_path));
} else if file_count <= 3 {
lines.push(format!("→ Read {} (best match)", suggest_path));
} else {
lines.push(format!("→ Read {}", suggest_path));
}
}
let is_small_set = file_count <= 5;
let mut def_expanded_count = 0usize;
for (file_idx, fm) in file_map.iter().enumerate() {
let is_def = fm.is_definition;
let def_tag = if is_def { " [def]" } else { "" };
lines.push(format!(
"{}{}{}",
fm.file.relative_path,
def_tag,
size_tag(fm.file.size)
));
// Show preview
if !fm.line_content.is_empty() && (is_def || file_idx == 0 || is_small_set) {
let ranges_ref: Option<&[(u32, u32)]> = if fm.match_ranges.is_empty() {
None
} else {
Some(&fm.match_ranges)
};
lines.push(format!(
" {}: {}",
fm.line_number,
trauncate_line_for_ai(&fm.line_content, ranges_ref, MAX_PREVIEW)
));
// Auto-expand body context
if auto_expand_defs && !fm.context_after.is_empty() {
let expand_limit = if is_def {
let limit = if def_expanded_count == 0 {
MAX_DEF_EXPAND_FIRST
} else {
MAX_DEF_EXPAND
};
def_expanded_count += 1;
limit
} else if is_small_set && file_idx == 0 {
MAX_FIRST_MATCH_EXPAND
} else if is_small_set {
MAX_DEF_EXPAND
} else {
0
};
if expand_limit > 0 {
let start_line = fm.line_number + 1;
for (i, ctx) in fm.context_after.iter().take(expand_limit).enumerate() {
if ctx.trim().is_empty() {
break;
}
lines.push(format!(
" {}| {}",
start_line + i as u64,
trauncate_line_for_ai(ctx, None, MAX_PREVIEW)
));
}
}
}
}
}
if next_file_offset > 0 {
let cursor_id = cursor_store.store(next_file_offset);
lines.push(format!("\ncursor: {}", cursor_id));
}
lines.join("\n")
}
fn format_count(
items: &[GrepMatch],
files: &[&FileItem],
next_file_offset: usize,
cursor_store: &mut CursorStore,
) -> String {
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
let mut order: Vec<&str> = Vec::new();
for m in items {
let path = files[m.file_index].relative_path.as_str();
let count = counts.entry(path).or_insert_with(|| {
order.push(path);
0
});
*count += 1;
}
let mut lines: Vec<String> = Vec::new();
for path in &order {
lines.push(format!("{}: {}", path, counts[*path]));
}
if next_file_offset > 0 {
let cursor_id = cursor_store.store(next_file_offset);
lines.push(format!("\ncursor: {}", cursor_id));
}
lines.join("\n")
}
fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<FileMeta<'a>> {
let mut file_preview: Vec<FileMeta<'a>> = Vec::new();
let mut seen = std::collections::HashSet::new();
for m in items {
let file = files[m.file_index];
if seen.insert(&file.relative_path) {
file_preview.push(FileMeta {
file,
line_number: m.line_number,
line_content: m.line_content.clone(),
is_definition: m.is_definition,
match_ranges: m.match_byte_offsets.iter().copied().collect(),
context_after: m.context_after.clone(),
});
}
}
file_preview
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trunc_strips_whitespace() {
assert_eq!(trauncate_line_for_ai(" foo()", None, 180), "foo()");
assert_eq!(trauncate_line_for_ai(" bar ", None, 180), "bar");
assert_eq!(trauncate_line_for_ai(" ", None, 180), "");
}
#[test]
fn trunc_adjusts_match_ranges_after_strip() {
// " hello" — match on "hello" at bytes 4..9
let line = " hello";
let ranges = [(4, 9)];
let result = trauncate_line_for_ai(line, Some(&ranges), 180);
// After stripping 4 leading spaces, the trimmed line is "hello"
assert_eq!(result, "hello");
}
#[test]
fn trunc_long_line_centered() {
let line = format!("{}match_here{}", " ".repeat(8), "x".repeat(200));
let ranges = [(8u32, 18u32)];
let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
assert!(result.contains("match_here"));
assert!(result.len() <= 55); // budget + ellipsis chars
}
}
+679
View File
@@ -0,0 +1,679 @@
//! FFF MCP server — tool definitions and handlers.
//!
//! Uses the `rmcp` crate's `#[tool_router]` / `#[tool_handler]` macros
//! for declarative tool registration. Each tool method directly calls
//! `fff-core` APIs (no C FFI overhead).
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use fff_core::file_picker::FilePicker;
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
use crate::cursor::CursorStore;
use crate::output::{GrepFormatter, OutputMode, file_suffix};
/// Strip common delimiters and lowercase for fuzzy fallback queries.
fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if !matches!(c, ':' | '-' | '_') {
out.extend(c.to_lowercase());
}
}
out
}
/// Compute grep search options from output mode and context settings.
fn make_grep_options(
output_mode: OutputMode,
mode: GrepMode,
file_offset: usize,
context: Option<usize>,
) -> (GrepSearchOptions, bool) {
let is_usage = output_mode == OutputMode::Usage;
let matches_per_file = match output_mode {
OutputMode::FilesWithMatches => 1,
_ if is_usage => 8,
_ => 10,
};
let ctx_lines = if is_usage {
context.unwrap_or(1)
} else {
context.unwrap_or(0)
};
let auto_expand = !is_usage && ctx_lines == 0;
let after_ctx = if auto_expand { 8 } else { ctx_lines };
(
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: matches_per_file,
smart_case: true,
file_offset,
page_limit: 50,
mode,
time_budget_ms: 0,
before_context: ctx_lines,
after_context: after_ctx,
classify_definitions: true,
},
auto_expand,
)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FindFilesParams {
/// Fuzzy search query. Supports path prefixes and glob constraints.
pub query: String,
/// Max results (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
/// Cursor from previous result. Only use if previous results weren't sufficient.
pub cursor: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GrepParams {
/// Search text or regex query with optional constraint prefixes.
/// Matches within single lines only — use ONE specific term, not multiple words.
pub query: String,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
/// Cursor from previous result. Only use if previous results weren't sufficient.
pub cursor: Option<String>,
/// Output format (default 'content').
pub output_mode: Option<String>,
}
fn deserialize_patterns<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct PatternsVisitor;
impl<'de> de::Visitor<'de> for PatternsVisitor {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string, an array of strings, or a stringified JSON array")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
// Try to parse as JSON array first
if v.starts_with('[')
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(v)
{
return Ok(parsed);
}
Ok(vec![v.to_string()])
}
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
if v.starts_with('[')
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(&v)
{
return Ok(parsed);
}
Ok(vec![v])
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut values = Vec::new();
while let Some(value) = seq.next_element::<String>()? {
values.push(value);
}
Ok(values)
}
}
deserializer.deserialize_any(PatternsVisitor)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct MultiGrepParams {
/// Patterns to match (OR logic). Include all naming conventions: snake_case, PascalCase, camelCase.
#[serde(deserialize_with = "deserialize_patterns")]
pub patterns: Vec<String>,
/// File constraints (e.g. '*.{ts,tsx} !test/'). ALWAYS provide when possible.
pub constraints: Option<String>,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
/// Cursor from previous result.
pub cursor: Option<String>,
/// Output format (default 'content').
pub output_mode: Option<String>,
/// Context lines before/after each match.
pub context: Option<usize>,
}
#[derive(Clone)]
pub struct FffServer {
picker: SharedPicker,
#[allow(dead_code)]
frecency: SharedFrecency,
cursor_store: Arc<Mutex<CursorStore>>,
update_notice_sent: Arc<AtomicBool>,
tool_router: ToolRouter<Self>,
}
impl FffServer {
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
Self {
picker,
frecency,
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
update_notice_sent: Arc::new(AtomicBool::new(false)),
tool_router: Self::tool_router(),
}
}
/// Wait for the initial file scan to complete.
#[allow(dead_code)]
pub fn wait_for_scan(&self) {
loop {
let guard = self.picker.read().ok();
let is_scanning = guard
.as_ref()
.and_then(|g| g.as_ref())
.map(|p| p.is_scan_active())
.unwrap_or(true);
if !is_scanning {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
/// Lock the cursor store, returning an MCP error on poisoned mutex.
fn lock_cursors(&self) -> Result<std::sync::MutexGuard<'_, CursorStore>, ErrorData> {
self.cursor_store.lock().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire cursor store lock: {e}"), None)
})
}
/// If an update notice is available and hasn't been sent yet, append it
/// to the tool result. Called once per server lifetime (first tool call).
fn maybe_append_update_notice(&self, result: &mut CallToolResult) {
if self.update_notice_sent.swap(true, Ordering::Relaxed) {
return;
}
let notice = crate::update_check::get_update_notice();
if notice.is_empty() {
// Reset so the next call can try again (check may still be in flight)
self.update_notice_sent.store(false, Ordering::Relaxed);
return;
}
result.content.push(Content::text(notice));
}
/// Perform grep with auto-retry logic.
///
/// Acquires the picker read-lock once and holds it for the entire
/// operation, so `GrepResult` references are used directly — no cloning.
/// Always uses AI query parsing since this is an MCP server for AI agents.
fn perform_grep(
&self,
query: &str,
mode: GrepMode,
max_results: usize,
cursor_id: Option<&str>,
output_mode: OutputMode,
context: Option<usize>,
) -> Result<CallToolResult, ErrorData> {
let file_offset = cursor_id
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let (options, auto_expand) = make_grep_options(output_mode, mode, file_offset, context);
let ctx_lines = options.before_context;
// Acquire picker lock once for the entire operation.
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let files = picker.get_files();
let parser = QueryParser::new(AiGrepConfig);
let parsed = parser.parse(query);
let result = grep::grep_search(files, &parsed, &options);
if result.matches.is_empty() && file_offset == 0 {
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
let parts: Vec<&str> = query.split_whitespace().collect();
if parts.len() >= 2 {
let first_word = parts[0];
let is_valid_constraint = first_word.starts_with('!')
|| first_word.starts_with('*')
|| first_word.ends_with('/');
if !is_valid_constraint {
let rest_query = parts[1..].join(" ");
let rest_parsed = parser.parse(&rest_query);
let rest_text = rest_parsed.grep_text();
let retry_mode = if has_regex_metacharacters(&rest_text) {
GrepMode::Regex
} else {
mode
};
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
let retry_result = grep::grep_search(files, &rest_parsed, &retry_options);
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &retry_result.matches,
files: &retry_result.files,
total_matched: retry_result.matches.len(),
next_file_offset: retry_result.next_file_offset,
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
}
.format(&mut cs);
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 matches for '{}'. Auto-broadened to '{}':\n{}",
query, rest_query, text
))]));
}
}
}
// Fuzzy fallback for typo tolerance
let fuzzy_query = cleanup_fuzzy_query(query);
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
let fuzzy_parsed = parser.parse(&fuzzy_query);
let fuzzy_result = grep::grep_search(files, &fuzzy_parsed, &fuzzy_options);
if !fuzzy_result.matches.is_empty() {
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"0 exact matches. {} approximate:",
fuzzy_result.matches.len()
));
let mut current_file = "";
for m in fuzzy_result.matches.iter().take(3) {
let file = fuzzy_result.files[m.file_index];
if file.relative_path.as_str() != current_file {
current_file = &file.relative_path;
lines.push(current_file.to_string());
}
lines.push(format!(" {}: {}", m.line_number, m.line_content));
}
return Ok(CallToolResult::success(vec![Content::text(
lines.join("\n"),
)]));
}
// File path fallback: if query looks like a path, suggest the matching file
if query.contains('/') {
let file_parser = QueryParser::default();
let file_query = file_parser.parse(query);
let file_opts = FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(picker.base_path()),
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 1,
},
};
let file_result = FilePicker::fuzzy_search(files, &file_query, file_opts);
if let (Some(top), Some(score)) =
(file_result.items.first(), file_result.scores.first())
{
// Only suggest when the match is strong enough.
let query_len = query.len() as i32;
if score.base_score > query_len * 10 {
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 content matches. But there is a relevant file path: {}",
top.relative_path
))]));
}
}
}
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
if result.matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &result.matches,
files: &result.files,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
}
.format(&mut cs);
Ok(CallToolResult::success(vec![Content::text(text)]))
}
}
#[tool_router]
impl FffServer {
/// Fuzzy file search by name. Searches FILE NAMES, not file contents.
/// Use it when you need to find a file, not a definition.
/// Use grep instead for searching code content (definitions, usage patterns).
/// Supports fuzzy matching, path prefixes ('shc/'), and glob constraints.
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
#[tool(
name = "find_files",
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
)]
fn find_files(
&self,
Parameters(params): Parameters<FindFilesParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20);
let query = &params.query;
let page_offset = params
.cursor
.as_deref()
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let files = picker.get_files();
let base_path = picker.base_path();
let make_opts = |offset: usize| FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(base_path),
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset,
limit: max_results,
},
};
let parser = QueryParser::default();
let fff_query = parser.parse(query);
let result = FilePicker::fuzzy_search(files, &fff_query, make_opts(page_offset));
let total_files = result.total_files;
// Auto-retry with fewer terms if 3+ words return 0 results
let words: Vec<&str> = query.split_whitespace().collect();
let shorter = words.get(..2).map(|w| w.join(" "));
let (items, scores, total_matched) =
if result.items.is_empty() && words.len() >= 3 && page_offset == 0 {
if let Some(shorter) = &shorter {
let shorter_query = parser.parse(shorter);
let retry = FilePicker::fuzzy_search(files, &shorter_query, make_opts(0));
(retry.items, retry.scores, retry.total_matched)
} else {
(result.items, result.scores, result.total_matched)
}
} else {
(result.items, result.scores, result.total_matched)
};
if items.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 results ({} indexed)",
total_files
))]));
}
let mut lines: Vec<String> = Vec::new();
let top_item = items[0];
let is_exact_match = scores[0].exact_match;
if page_offset == 0 {
if is_exact_match {
lines.push(format!("→ Read {} (exact match!)", top_item.relative_path));
} else if scores.len() < 2 || scores[0].total > scores[1].total.saturating_mul(2) {
lines.push(format!(
"→ Read {} (best match — Read this file directly)",
top_item.relative_path
));
}
}
let next_offset = page_offset + items.len();
let has_more = next_offset < total_matched;
if has_more {
lines.push(format!("{}/{} matches", items.len(), total_matched));
}
for item in &items {
lines.push(format!(
"{}{}",
item.relative_path,
file_suffix(item.git_status, item.total_frecency_score)
));
}
if has_more {
let mut cs = self.lock_cursors()?;
let cursor_id = cs.store(next_offset);
lines.push(format!("cursor: {}", cursor_id));
}
let mut result = CallToolResult::success(vec![Content::text(lines.join("\n"))]);
self.maybe_append_update_notice(&mut result);
Ok(result)
}
/// Search file contents for text patterns. This is the DEFAULT search tool.
/// Prefer plain text over regex. Filter files with constraints.
#[tool(
name = "grep",
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules."
)]
fn grep(
&self,
Parameters(params): Parameters<GrepParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20);
let output_mode = OutputMode::new(params.output_mode.as_deref());
let parsed = QueryParser::new(AiGrepConfig).parse(&params.query);
let grep_text = parsed.grep_text();
let mode = if has_regex_metacharacters(&grep_text) {
GrepMode::Regex
} else {
GrepMode::PlainText
};
let mut result = self.perform_grep(
&params.query,
mode,
max_results,
params.cursor.as_deref(),
output_mode,
None,
)?;
self.maybe_append_update_notice(&mut result);
Ok(result)
}
/// Search file contents for lines matching ANY of multiple patterns (OR logic).
/// Patterns are literal text — NEVER escape special characters.
#[tool(
name = "multi_grep",
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax."
)]
fn multi_grep(
&self,
Parameters(params): Parameters<MultiGrepParams>,
) -> Result<CallToolResult, ErrorData> {
let mut result = self.multi_grep_inner(params)?;
self.maybe_append_update_notice(&mut result);
Ok(result)
}
}
impl FffServer {
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20);
let output_mode = OutputMode::new(params.output_mode.as_deref());
let file_offset = params
.cursor
.as_deref()
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let (options, auto_expand) = make_grep_options(
output_mode,
GrepMode::PlainText,
file_offset,
params.context,
);
let ctx_lines = options.before_context;
let constraint_query = params.constraints.as_deref().unwrap_or("");
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
let parsed_constraints = parser.parse(constraint_query);
let constraints = parsed_constraints.constraints.as_slice();
let files = picker.get_files();
let result = grep::multi_grep_search(files, &patterns_refs, constraints, &options);
let file_refs: Vec<&FileItem> = result.files.to_vec();
if result.matches.is_empty() && file_offset == 0 {
// Fallback: try individual patterns with plain grep
let (fallback_options, _) =
make_grep_options(output_mode, GrepMode::PlainText, 0, params.context);
let fallback_options = GrepSearchOptions {
time_budget_ms: 3000,
before_context: 0,
..fallback_options
};
for pat in &params.patterns {
let full_query: Cow<str> = if !constraint_query.is_empty() {
Cow::Owned(format!("{} {}", constraint_query, pat))
} else {
Cow::Borrowed(pat)
};
let parsed = parser.parse(&full_query);
let fb_result = grep::grep_search(files, &parsed, &fallback_options);
if !fb_result.matches.is_empty() {
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &fb_result.matches,
files: &fb_file_refs,
total_matched: fb_result.matches.len(),
next_file_offset: fb_result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: false,
auto_expand_defs: auto_expand,
}
.format(&mut cs);
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 multi-pattern matches. Plain grep fallback for \"{}\":\n{}",
pat, text
))]));
}
}
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
if result.matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &result.matches,
files: &file_refs,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
}
.format(&mut cs);
Ok(CallToolResult::success(vec![Content::text(text)]))
}
}
#[tool_handler]
impl ServerHandler for FffServer {
fn get_info(&self) -> ServerInfo {
let notice = crate::update_check::get_update_notice();
let instructions = if notice.is_empty() {
crate::MCP_INSTRUCTIONS.to_string()
} else {
format!("{}{}", crate::MCP_INSTRUCTIONS, notice)
};
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("fff", env!("CARGO_PKG_VERSION")))
.with_instructions(instructions)
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Background update checker — compares the embedded build hash against
//! the latest GitHub release tag to surface upgrade notices in MCP instructions.
use std::sync::OnceLock;
const REPO: &str = "dmtrKovalenko/fff.nvim";
const BUILD_HASH: &str = env!("FFF_GIT_HASH");
/// Holds the result of the update check (empty string = up to date or check failed).
static UPDATE_NOTICE: OnceLock<String> = OnceLock::new();
/// Returns the update notice if the check has completed, empty string otherwise.
pub fn get_update_notice() -> &'static str {
UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("")
}
/// Kick off the update check in a background thread so it never blocks the server.
pub fn spawn_update_check() {
std::thread::spawn(|| {
let notice = check_latest_release();
let _ = UPDATE_NOTICE.set(notice);
});
}
/// Fetch the latest release tag from GitHub and compare against the build hash.
fn check_latest_release() -> String {
match fetch_latest_tag() {
Ok(tag) => compare_versions(BUILD_HASH, &tag),
Err(_) => String::new(),
}
}
/// Compare a build hash against a release tag.
/// Returns an update notice string, or empty if up-to-date.
fn compare_versions(build_hash: &str, release_tag: &str) -> String {
let tag = release_tag.trim();
if tag.is_empty() || build_hash == "unknown" {
return String::new();
}
let our_short = &build_hash[..build_hash.len().min(tag.len())];
if our_short == tag {
return String::new();
}
format!(
"\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
)
}
/// Shell out to curl to fetch the latest release tag name from GitHub API.
fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("curl")
.args([
"-fsSL",
"--max-time",
"5",
"-H",
"Accept: application/vnd.github.v3+json",
&format!("https://api.github.com/repos/{REPO}/releases?per_page=1"),
])
.output()?;
if !output.status.success() {
return Err("curl failed".into());
}
let body = String::from_utf8(output.stdout)?;
let releases: Vec<serde_json::Value> = serde_json::from_str(&body)?;
let tag = releases
.first()
.and_then(|r| r.get("tag_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(tag)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_check_end_to_end() {
// Fetch the actual latest release tag from GitHub
let tag = fetch_latest_tag().expect("Failed to fetch latest release tag from GitHub");
assert!(!tag.is_empty(), "Release tag should not be empty");
let notice = compare_versions(BUILD_HASH, &tag);
let build_short = &BUILD_HASH[..BUILD_HASH.len().min(7)];
if BUILD_HASH.starts_with(tag.trim()) || tag.trim().starts_with(BUILD_HASH) {
// If by chance we're on the exact release commit
assert!(notice.is_empty(), "Should be empty when hashes match");
} else {
assert!(
notice.contains("fff update available"),
"Expected update notice for mismatched hashes (build: {}, release: {}), got: '{}'",
build_short,
tag.trim(),
notice
);
assert!(
notice.contains(tag.trim()),
"Notice should contain release tag"
);
assert!(
notice.contains(build_short),
"Notice should contain our short hash"
);
assert!(
notice.contains("install-mcp.sh"),
"Notice should contain install command"
);
}
}
}
+4 -7
View File
@@ -7,6 +7,10 @@ edition = "2024"
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[features]
default = []
zlob = ["fff-core/zlob"]
[[bin]]
name = "test_watcher"
path = "src/bin/test_watcher.rs"
@@ -61,9 +65,6 @@ once_cell = "1.20.2"
pathdiff = "0.2.1"
serde = { version = "1.0", features = ["derive"] }
smartstring = { version = "1.0.1", features = ["serde"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
@@ -78,7 +79,3 @@ harness = false
name = "query_tracker_bench"
harness = false
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
# On Windows, git2 uses the native SChannel TLS backend
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
+86 -84
View File
@@ -1,8 +1,9 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
use fff_nvim::types::PaginationArgs;
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// Initialize tracing to output to console
@@ -19,20 +20,27 @@ fn init_tracing() {
// .try_init();
}
/// Initialize FilePicker and insert into global state
fn init_file_picker_internal(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
/// Initialize FilePicker using shared state
fn init_file_picker_internal(
path: &str,
shared_picker: &SharedPicker,
shared_frecency: &SharedFrecency,
) -> Result<(), String> {
FilePicker::new_with_shared_state(
path.to_string(),
false,
FFFMode::Neovim,
Arc::clone(shared_picker),
Arc::clone(shared_frecency),
)
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
}
/// Helper function to wait for scanning to complete and get file count
fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
fn wait_for_scan_completion(
shared_picker: &SharedPicker,
timeout_secs: u64,
) -> Result<usize, String> {
let start = std::time::Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut last_log = std::time::Instant::now();
@@ -42,7 +50,7 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
iteration += 1;
{
let picker_guard = FILE_PICKER
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -91,9 +99,9 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
}
}
/// Get files from the global FILE_PICKER
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
/// Get files from the shared picker
fn get_files_snapshot(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -103,9 +111,9 @@ fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
}
}
/// Clean up global state
fn cleanup_global_state() {
if let Ok(mut picker_guard) = FILE_PICKER.write() {
/// Clean up shared state
fn cleanup_shared_state(shared_picker: &SharedPicker) {
if let Ok(mut picker_guard) = shared_picker.write() {
if let Some(mut picker) = picker_guard.take() {
picker.stop_background_monitor();
}
@@ -113,7 +121,7 @@ fn cleanup_global_state() {
}
/// Initialize FilePicker once and return files snapshot
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String> {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
@@ -125,32 +133,24 @@ fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let files = picker.get_files();
if !files.is_empty() {
eprintln!(" Reusing existing index with {} files", files.len());
return Ok(files.to_vec());
}
}
}
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
init_file_picker_internal(&canonical_path.to_string_lossy())?;
init_file_picker_internal(
&canonical_path.to_string_lossy(),
&shared_picker,
&shared_frecency,
)?;
eprintln!(" Waiting for background scan to complete...");
let file_count = wait_for_scan_completion(120)?;
let file_count = wait_for_scan_completion(&shared_picker, 120)?;
eprintln!(
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
file_count
);
get_files_snapshot()
let files = get_files_snapshot(&shared_picker)?;
Ok((files, shared_picker, shared_frecency))
}
/// Benchmark for indexing the big-repo directory
@@ -179,21 +179,23 @@ fn bench_indexing(c: &mut Criterion) {
group.bench_function("index_big_repo", |b| {
b.iter(|| {
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
let sp: SharedPicker = Arc::new(RwLock::new(None));
let sf: SharedFrecency = Arc::new(RwLock::new(None));
let start = std::time::Instant::now();
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
.expect("Failed to init FilePicker");
match wait_for_scan_completion(120) {
match wait_for_scan_completion(&sp, 120) {
Ok(file_count) => {
let elapsed = start.elapsed();
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
cleanup_shared_state(&sp);
file_count
}
Err(e) => {
eprintln!(" ✗ Error: {}", e);
cleanup_shared_state(&sp);
0
}
}
@@ -205,8 +207,8 @@ fn bench_indexing(c: &mut Criterion) {
/// Benchmark for searching with various query patterns
fn bench_search_queries(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprint!("Failed to setup picker {e:?}");
return;
@@ -224,16 +226,18 @@ fn bench_search_queries(c: &mut Criterion) {
("partial", "src/lib"),
];
let parser = QueryParser::default();
for (name, query) in test_queries {
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &query| {
let parsed = parser.parse(query);
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &_query| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -254,8 +258,8 @@ fn bench_search_queries(c: &mut Criterion) {
/// Benchmark search with different thread counts
fn bench_search_thread_scaling(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
return;
@@ -266,6 +270,8 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let thread_counts = vec![1, 2, 4, 8];
for threads in thread_counts {
@@ -276,11 +282,10 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed),
FuzzySearchOptions {
max_threads: threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -302,8 +307,8 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
/// Benchmark search with different result limits
fn bench_search_result_limits(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
return;
@@ -314,6 +319,8 @@ fn bench_search_result_limits(c: &mut Criterion) {
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result_limits = vec![10, 50, 100, 500];
for limit in result_limits {
@@ -321,11 +328,10 @@ fn bench_search_result_limits(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -346,8 +352,8 @@ fn bench_search_result_limits(c: &mut Criterion) {
/// Benchmark search algorithm performance scaling with file count
fn bench_search_scalability(c: &mut Criterion) {
let all_files = match setup_once() {
Ok(files) => files,
let (all_files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
return;
@@ -366,6 +372,8 @@ fn bench_search_scalability(c: &mut Criterion) {
group.sample_size(50);
let query = "controller";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
for count in file_counts {
@@ -378,11 +386,10 @@ fn bench_search_scalability(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -403,8 +410,8 @@ fn bench_search_scalability(c: &mut Criterion) {
/// Benchmark search performance with different ordering modes
fn bench_search_ordering(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
return;
@@ -414,18 +421,19 @@ fn bench_search_ordering(c: &mut Criterion) {
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed_controller = parser.parse("controller");
let parsed_mod = parser.parse("mod");
// Benchmark normal order (descending)
group.bench_function("normal_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -445,11 +453,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -469,11 +476,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -492,11 +498,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -516,11 +521,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -539,11 +543,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -563,8 +566,8 @@ fn bench_search_ordering(c: &mut Criterion) {
/// Benchmark pagination: first page vs deep page
fn bench_pagination_performance(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
return;
@@ -575,6 +578,8 @@ fn bench_pagination_performance(c: &mut Criterion) {
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let page_size = 40;
// Benchmark first page (uses partial sort optimization)
@@ -582,11 +587,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -606,11 +610,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -630,11 +633,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -1,5 +1,5 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::query_tracker::QueryTracker;
use fff_core::query_tracker::QueryTracker;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use std::path::PathBuf;
+1 -2
View File
@@ -87,8 +87,7 @@ fn main() {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
+4 -1
View File
@@ -71,11 +71,14 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
page_limit: 100, // Get plenty of results
mode: GrepMode::Fuzzy,
time_budget_ms: 0, // No time limit — search all files
before_context: 0,
after_context: 0,
classify_definitions: false,
};
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = grep_search(files, query, parsed, &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
eprintln!("══════════════════════════════════════════════════════════════");
+97 -14
View File
@@ -10,7 +10,7 @@ use fff_core::FileItem;
/// Usage:
/// cargo build --release --bin grep_profiler
/// ./target/release/grep_profiler [--path /path/to/repo]
use fff_core::grep::{GrepSearchOptions, grep_search, parse_grep_query};
use fff_core::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::time::{Duration, Instant};
@@ -119,6 +119,10 @@ struct GrepBench<'a> {
impl<'a> GrepBench<'a> {
fn new(files: &'a [FileItem]) -> Self {
Self::with_mode(files, GrepMode::PlainText)
}
fn with_mode(files: &'a [FileItem], mode: GrepMode) -> Self {
Self {
files,
options: GrepSearchOptions {
@@ -127,8 +131,11 @@ impl<'a> GrepBench<'a> {
smart_case: true,
file_offset: 0,
page_limit: 50,
mode: Default::default(),
mode,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
},
}
}
@@ -137,13 +144,9 @@ impl<'a> GrepBench<'a> {
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = grep_search(self.files, query, parsed, &self.options);
let result = grep_search(self.files, &parsed, &self.options);
let elapsed = start.elapsed();
(
elapsed,
result.total_match_count,
result.total_files_searched,
)
(elapsed, result.matches.len(), result.total_files_searched)
}
/// Benchmark a query with multiple iterations
@@ -224,7 +227,7 @@ fn main() {
eprintln!("Repository: {:?}", canonical);
// Direct file loading (no background thread)
eprintln!("\n[1/5] Loading files...");
eprintln!("\n[1/7] Loading files...");
let load_start = Instant::now();
let files = load_files(&canonical);
let load_time = load_start.elapsed();
@@ -240,7 +243,7 @@ fn main() {
let bench = GrepBench::new(&files);
eprintln!("[2/5] Cold cache benchmarks (first search, mmap not yet loaded)");
eprintln!("[2/7] Cold cache benchmarks (first search, mmap not yet loaded)");
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
print_header();
@@ -263,7 +266,7 @@ fn main() {
print_row(name, &stats, matches, files_searched, 1);
}
eprintln!("\n[3/5] Warm cache benchmarks (mmap cache populated)");
eprintln!("\n[3/7] Warm cache benchmarks (plain text, mmap cache populated)");
eprintln!(" Running 3 warmup iterations, then measuring.\n");
print_header();
@@ -297,7 +300,84 @@ fn main() {
print_row(name, &stats, matches, files_searched, *iters);
}
eprintln!("\n[4/5] Incremental typing simulation");
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
eprintln!(" Running 3 warmup iterations, then measuring.\n");
print_header();
let fuzzy_bench = GrepBench::with_mode(&files, GrepMode::Fuzzy);
let fuzzy_queries: Vec<(&str, &str, usize)> = vec![
("fuzzy_exact", "mutex_lock", 15),
("fuzzy_typo", "mutx_lock", 15),
("fuzzy_camel", "InodeOps", 15),
("fuzzy_abbrev", "sched_rt", 15),
("fuzzy_short", "kfr", 15),
("fuzzy_common", "return", 10),
("fuzzy_define", "MODULE_LICENSE", 15),
("fuzzy_struct", "file_operations", 15),
("fuzzy_long", "static_int_init", 15),
("fuzzy_path", "printk *.c", 15),
];
// Warmup
for (_, query, _) in &fuzzy_queries {
for _ in 0..3 {
fuzzy_bench.run_once(query);
}
}
for (name, query, iters) in &fuzzy_queries {
let (stats, matches, files_searched) = fuzzy_bench.bench_query(query, *iters);
print_row(name, &stats, matches, files_searched, *iters);
}
// ── Fuzzy incremental typing ────────────────────────────────────────
eprintln!("\n[5/7] Fuzzy incremental typing simulation");
eprintln!(" Simulates user typing character by character (fuzzy mode).\n");
let fuzzy_typing_sequences: Vec<(&str, Vec<&str>)> = vec![
(
"mutex_lock",
vec![
"m",
"mu",
"mut",
"mute",
"mutex",
"mutex_",
"mutex_l",
"mutex_lo",
"mutex_loc",
"mutex_lock",
],
),
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
];
for (name, sequence) in &fuzzy_typing_sequences {
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
eprintln!(
" {:>16} | {:>8} | {:>6} | {:>6}",
"Query", "Latency", "Match", "Files"
);
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
for prefix in sequence {
let (elapsed, matches, files_searched) = fuzzy_bench.run_once(prefix);
eprintln!(
" {:>16} | {:>8} | {:>6} | {:>6}",
format!("\"{}\"", prefix),
fmt_dur(elapsed),
matches,
files_searched,
);
}
eprintln!();
}
eprintln!("[6/7] Incremental typing simulation (plain text)");
eprintln!(" Simulates user typing character by character.\n");
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
@@ -342,7 +422,7 @@ fn main() {
eprintln!();
}
eprintln!("[5/5] Pagination benchmark");
eprintln!("[7/7] Pagination benchmark");
eprintln!(" Testing page_offset performance for common query.\n");
let pagination_query = "return";
@@ -367,9 +447,12 @@ fn main() {
page_limit: 50,
mode: Default::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(&files, pagination_query, parsed, &opts);
let result = grep_search(&files, &parsed, &opts);
let elapsed = start.elapsed();
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
+30 -5
View File
@@ -1,3 +1,4 @@
use fff_core::FFFQuery;
use fff_core::FileItem;
/// FFF vs ripgrep comparison benchmark
///
@@ -204,11 +205,34 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
page_limit: usize::MAX,
mode: Default::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed, &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
(result.total_match_count, elapsed)
(result.matches.len(), elapsed)
}
#[allow(dead_code)]
fn benchmark_fff_smart_case(files: &[FileItem], parsed: &FFFQuery<'_>) -> (usize, Duration) {
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: usize::MAX,
smart_case: true,
file_offset: 0,
page_limit: 5000,
mode: Default::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
/// fff paginated: first 50 results only (real UI scenario).
@@ -222,12 +246,13 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
page_limit: 50,
mode: Default::default(),
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed, &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
// Use matches.len() — the actual truncated page the UI would display,
// not total_match_count which includes overshoot from parallel batches.
(result.matches.len(), elapsed)
}
+31 -22
View File
@@ -1,6 +1,7 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
@@ -60,6 +61,7 @@ fn format_bytes(bytes: usize) -> String {
}
fn test_search_memory_pattern(
shared_picker: &SharedPicker,
name: &str,
iterations: usize,
query_pattern: impl Fn(usize) -> String,
@@ -82,14 +84,13 @@ fn test_search_memory_pattern(
let query = query_pattern(i);
let (result_count, _total_matched) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let parser = QueryParser::default();
let parsed = parser.parse(&query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 1 + (i % 4),
current_file: None,
@@ -177,20 +178,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Test directory: {}", base_path);
println!();
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
// Initialize FilePicker
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Initializing FilePicker...");
*file_picker_guard = Some(FilePicker::new(base_path.clone())?);
}
}
println!("Initializing FilePicker...");
FilePicker::new_with_shared_state(
base_path.clone(),
false,
FFFMode::Neovim,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)?;
// Wait for initial scan
println!("Waiting for file scan...");
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
if let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
&& !picker.is_scan_active()
&& !picker.get_files().is_empty()
{
@@ -200,8 +206,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let file_count = {
let file_picker_guard = FILE_PICKER.read()?;
file_picker_guard.as_ref().unwrap().get_files().len()
let guard = shared_picker.read().unwrap();
guard.as_ref().unwrap().get_files().len()
};
println!("📊 Found {} files", file_count);
@@ -216,10 +222,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Test different memory patterns
// 1. Repeated same query - should have minimal growth if caching works
test_search_memory_pattern("Same Query Repeated (1000x)", 1000, |_| "test".to_string())?;
test_search_memory_pattern(&shared_picker, "Same Query Repeated (1000x)", 1000, |_| {
"test".to_string()
})?;
// 2. Cycling through different queries
test_search_memory_pattern("Cycling Queries (1000x)", 1000, |i| {
test_search_memory_pattern(&shared_picker, "Cycling Queries (1000x)", 1000, |i| {
let queries = [
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
];
@@ -227,24 +235,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
})?;
// 3. Unique queries each time - worst case for any caching
test_search_memory_pattern("Unique Queries (500x)", 500, |i| {
test_search_memory_pattern(&shared_picker, "Unique Queries (500x)", 500, |i| {
format!("unique_query_{}", i)
})?;
// 4. Queries that return many results
test_search_memory_pattern(
&shared_picker,
"High Result Count (500x)",
500,
|_| "a".to_string(), // Single character likely to match many files
)?;
// 5. Queries with no results
test_search_memory_pattern("No Results (500x)", 500, |_| {
test_search_memory_pattern(&shared_picker, "No Results (500x)", 500, |_| {
"zzzz_no_match_expected".to_string()
})?;
// 6. Long intensive test
test_search_memory_pattern("Long Intensive Test (2000x)", 2000, |i| {
test_search_memory_pattern(&shared_picker, "Long Intensive Test (2000x)", 2000, |i| {
let patterns = [
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
];
+25 -24
View File
@@ -1,9 +1,12 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
/// Wait for background scan to complete
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut iteration = 0;
@@ -11,7 +14,7 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
loop {
iteration += 1;
let picker_guard = FILE_PICKER
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -45,21 +48,9 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
}
}
/// Initialize FilePicker and insert into global state
fn init_file_picker(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Get files snapshot from global state
fn get_files() -> Result<Vec<FileItem>, String> {
let picker_guard = FILE_PICKER
/// Get files snapshot from shared state
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
@@ -82,17 +73,28 @@ fn main() {
let canonical_path =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
FilePicker::new_with_shared_state(
canonical_path.to_string_lossy().to_string(),
false,
FFFMode::Neovim,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)
.expect("Failed to init FilePicker");
// Give background thread time to start
std::thread::sleep(Duration::from_millis(200));
eprintln!("Waiting for scan to complete...");
let file_count = wait_for_scan(120).expect("Failed to wait for scan");
let file_count = wait_for_scan(&shared_picker, 120).expect("Failed to wait for scan");
eprintln!("✓ Indexed {} files\n", file_count);
let files = get_files().expect("Failed to get files");
let files = get_files(&shared_picker).expect("Failed to get files");
// Test queries representing different search patterns
let test_queries = vec![
@@ -124,8 +126,7 @@ fn main() {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
+26 -31
View File
@@ -1,7 +1,8 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant};
@@ -77,24 +78,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Test directory: {}", base_path);
println!();
// Initialize the file picker directly
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
// Initialize the file picker
println!("📁 Initializing FilePicker...");
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Creating new FilePicker for path: {}", base_path);
match FilePicker::new(base_path.clone()) {
Ok(picker) => {
println!("FilePicker created successfully");
*file_picker_guard = Some(picker);
}
Err(e) => {
eprintln!("Failed to create FilePicker: {:?}", e);
std::process::exit(1);
}
}
}
}
FilePicker::new_with_shared_state(
base_path.clone(),
false,
FFFMode::Neovim,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)?;
// Wait for initial scan to complete
println!("⏳ Waiting for initial file scan to complete...");
@@ -102,8 +98,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut scan_completed = false;
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
if let Ok(guard) = shared_picker.read()
&& let Some(ref picker) = *guard
{
if !picker.is_scan_active() {
println!("Scan inactive, checking file count...");
@@ -128,10 +124,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// If async scan didn't work, trigger a manual scan
if !scan_completed {
println!("Triggering manual rescan...");
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
if let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
match picker.trigger_rescan() {
match picker.trigger_rescan(&shared_frecency) {
Ok(_) => println!("Manual rescan completed"),
Err(e) => println!("Manual rescan failed: {:?}", e),
}
@@ -139,8 +135,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let initial_file_count = {
let file_picker_guard = FILE_PICKER.read()?;
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let files = picker.get_files();
println!("Found {} files in picker", files.len());
if !files.is_empty() {
@@ -151,7 +147,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
files.len()
} else {
println!("No picker found in FILE_PICKER static!");
println!("No picker found!");
0
}
};
@@ -199,13 +195,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let search_start = Instant::now();
let parser = QueryParser::default();
let (result_count, search_duration) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let guard = shared_picker.read().unwrap();
if let Some(ref picker) = *guard {
let parsed = parser.parse(query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads,
current_file: None,
+42 -47
View File
@@ -4,33 +4,16 @@
use fff_core::file_picker::FilePicker;
use fff_core::git::format_git_status;
use fff_core::{FILE_PICKER, FRECENCY, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{
FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
};
use std::env;
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
fn cleanup_global_state() {
// Clean up file picker
{
let mut file_picker = FILE_PICKER.write().unwrap();
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
drop(picker);
println!("🧹 FilePicker cleaned up");
}
}
// Clean up frecency tracker
{
let mut frecency = FRECENCY.write().unwrap();
*frecency = None;
println!("🧹 Frecency tracker cleaned up");
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let base_path = if args.len() > 1 {
@@ -43,28 +26,39 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
// Clone for signal handler
let picker_for_cleanup = Arc::clone(&shared_picker);
ctrlc::set_handler(move || {
println!("\n🛑 Received interrupt signal, shutting down...");
cleanup_global_state();
if let Ok(mut guard) = picker_for_cleanup.write() {
if let Some(mut picker) = guard.take() {
picker.stop_background_monitor();
println!("🧹 FilePicker cleaned up");
}
}
r.store(false, Ordering::SeqCst);
std::process::exit(0);
})?;
let mut git_stats = std::collections::HashMap::new();
// Initialize the global file picker using lib.rs function
{
let mut file_picker = FILE_PICKER.write().unwrap();
if file_picker.is_some() {
eprintln!("❌ FilePicker already initialized");
std::process::exit(1);
}
*file_picker = Some(FilePicker::new(base_path.clone())?);
}
// Get initial file count from global state
// Initialize the file picker using shared state
FilePicker::new_with_shared_state(
base_path.clone(),
false,
FFFMode::default(),
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
)?;
// Get initial file count from shared state
let initial_count = {
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let files = guard.as_ref().unwrap().get_files();
println!("Initial file count: {}", files.len());
if !files.is_empty() {
@@ -96,8 +90,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
iteration += 1;
let current_count = {
let file_picker = FILE_PICKER.read().unwrap();
file_picker.as_ref().unwrap().get_files().len()
let guard = shared_picker.read().unwrap();
guard.as_ref().unwrap().get_files().len()
};
if current_count != last_count {
@@ -111,13 +105,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
// Show some recently added files
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let files = guard.as_ref().unwrap().get_files();
let newest_files = files.iter().rev().take(added.min(3));
for file in newest_files {
println!(" {}", file.relative_path);
}
drop(file_picker);
} else {
let removed = last_count - current_count;
println!(
@@ -136,8 +129,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
timestamp, current_count
);
let file_picker = FILE_PICKER.read().unwrap();
let current_files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let current_files = guard.as_ref().unwrap().get_files();
git_stats.clear();
for file in current_files {
@@ -156,14 +149,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if iteration % 40 == 0 {
let timestamp = chrono::Local::now().format("%H:%M:%S");
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let guard = shared_picker.read().unwrap();
let files = guard.as_ref().unwrap().get_files();
let parser = QueryParser::default();
let parsed = parser.parse("rs");
let search_results = FilePicker::fuzzy_search(
files,
"rs",
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 2,
current_file: None,
@@ -197,13 +189,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
score.total
);
}
drop(file_picker);
}
io::stdout().flush().unwrap();
}
// Clean up before exit
cleanup_global_state();
if let Ok(mut guard) = shared_picker.write() {
if let Some(mut picker) = guard.take() {
picker.stop_background_monitor();
}
}
Ok(())
}
+190
View File
@@ -0,0 +1,190 @@
use mlua::prelude::*;
use std::fmt::Write as _;
use std::io::{Read, Seek, SeekFrom};
// Byte category colors (matching hexyl's default theme)
const COLOR_OFFSET: &str = "#888888";
const COLOR_NULL: &str = "#555753";
const COLOR_ASCII_PRINTABLE: &str = "#06989a";
const COLOR_ASCII_WHITESPACE: &str = "#4e9a06";
const COLOR_ASCII_OTHER: &str = "#4e9a06";
const COLOR_NON_ASCII: &str = "#c4a000";
fn byte_color(b: u8) -> &'static str {
match b {
0x00 => COLOR_NULL,
0x20 | 0x09 | 0x0a | 0x0d => COLOR_ASCII_WHITESPACE,
0x21..=0x7e => COLOR_ASCII_PRINTABLE,
0x01..=0x1f | 0x7f => COLOR_ASCII_OTHER,
_ => COLOR_NON_ASCII,
}
}
fn byte_char(b: u8) -> char {
match b {
0x20..=0x7e => b as char,
_ => '.',
}
}
const BYTES_PER_LINE: usize = 16;
struct Span {
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
}
/// Push a span, merging with the previous one if same line and color.
fn push_span(
spans: &mut Vec<Span>,
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
) {
if let Some(last) = spans.last_mut() {
// Merge if same line, same color, and adjacent (allow small gaps for spaces between hex pairs)
if last.line == line && std::ptr::eq(last.color, color) && col_start <= last.col_end + 1 {
last.col_end = col_end;
return;
}
}
spans.push(Span {
line,
col_start,
col_end,
color,
});
}
/// Format raw bytes into hex dump lines with coalesced highlight spans.
///
/// Layout per line:
/// ```text
/// XXXXXXXX HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH CCCCCCCCCCCCCCCC
/// ```
fn format_hex_dump(raw_bytes: &[u8], base_offset: u64) -> (Vec<String>, Vec<Span>) {
let mut lines = Vec::new();
let mut spans = Vec::new();
for (chunk_idx, chunk) in raw_bytes.chunks(BYTES_PER_LINE).enumerate() {
let addr = base_offset + (chunk_idx * BYTES_PER_LINE) as u64;
let mut line = format!("{addr:08x} ");
// Offset label highlight
push_span(&mut spans, chunk_idx, 0, 8, COLOR_OFFSET);
// Hex pairs with a gap after 8 bytes
for (i, &b) in chunk.iter().enumerate() {
if i == 8 {
line.push(' ');
}
let col = line.len();
push_span(&mut spans, chunk_idx, col, col + 2, byte_color(b));
write!(line, "{b:02x} ").unwrap();
}
// Pad if the last line is short
if chunk.len() < BYTES_PER_LINE {
let missing = BYTES_PER_LINE - chunk.len();
let mut pad = missing * 3;
if chunk.len() <= 8 {
pad += 1;
}
for _ in 0..pad {
line.push(' ');
}
}
// Separator before char panel
line.push(' ');
// Character panel — consecutive same-color chars merge automatically
let char_start = line.len();
for (i, &b) in chunk.iter().enumerate() {
let col = char_start + i;
push_span(&mut spans, chunk_idx, col, col + 1, byte_color(b));
line.push(byte_char(b));
}
lines.push(line);
}
(lines, spans)
}
/// Generate a hex dump for a binary file with paging support and highlight data.
///
/// Returns a Lua table:
/// ```text
/// {
/// lines: string[],
/// highlights: {line_0idx, col_start, col_end, color}[],
/// has_more: bool,
/// next_offset: number,
/// }
/// ```
pub fn hex_dump(
lua: &Lua,
(file_path, offset, length): (String, Option<u64>, Option<u64>),
) -> LuaResult<LuaValue> {
let offset = offset.unwrap_or(0);
let length = length.unwrap_or(4096);
let file = std::fs::File::open(&file_path)
.map_err(|e| LuaError::RuntimeError(format!("Failed to open file: {e}")))?;
let file_size = file
.metadata()
.map_err(|e| LuaError::RuntimeError(format!("Failed to get metadata: {e}")))?
.len();
let table = lua.create_table()?;
if offset >= file_size {
table.set("lines", lua.create_table()?)?;
table.set("highlights", lua.create_table()?)?;
table.set("has_more", false)?;
table.set("next_offset", file_size)?;
return Ok(LuaValue::Table(table));
}
let mut reader = std::io::BufReader::new(file);
reader
.seek(SeekFrom::Start(offset))
.map_err(|e| LuaError::RuntimeError(format!("Failed to seek: {e}")))?;
let mut raw_bytes = Vec::with_capacity(length as usize);
reader
.by_ref()
.take(length)
.read_to_end(&mut raw_bytes)
.map_err(|e| LuaError::RuntimeError(format!("Failed to read: {e}")))?;
let (plain_lines, hl_spans) = format_hex_dump(&raw_bytes, offset);
let lines_table = lua.create_table()?;
for (i, line) in plain_lines.iter().enumerate() {
lines_table.set(i + 1, line.as_str())?;
}
table.set("lines", lines_table)?;
let highlights_table = lua.create_table()?;
for (i, span) in hl_spans.iter().enumerate() {
let hl = lua.create_table()?;
hl.raw_set(1, span.line)?;
hl.raw_set(2, span.col_start)?;
hl.raw_set(3, span.col_end)?;
hl.raw_set(4, span.color)?;
highlights_table.raw_set(i + 1, hl)?;
}
table.set("highlights", highlights_table)?;
let bytes_read = raw_bytes.len() as u64;
let next_offset = offset + bytes_read;
table.set("has_more", next_offset < file_size)?;
table.set("next_offset", next_offset)?;
Ok(LuaValue::Table(table))
}
+172 -36
View File
@@ -2,16 +2,22 @@ use crate::path_shortening::shorten_path_with_cache;
use error::{IntoCoreError, IntoLuaResult};
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::path_utils::expand_tilde;
use fff_core::query_tracker::QueryTracker;
use fff_core::{DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
use fff_core::{
DbHealthChecker, Error, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, Score,
SearchResult, SharedFrecency, SharedPicker, SharedQueryTracker,
};
use mimalloc::MiMalloc;
use mlua::prelude::*;
use once_cell::sync::Lazy;
use path_shortening::PathShortenStrategy;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
mod error;
mod hex_dump;
mod log;
mod lua_types;
mod path_shortening;
@@ -19,6 +25,12 @@ mod path_shortening;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
// the global state for neovim lives here for efficiency
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
pub fn init_db(
_: &Lua,
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
@@ -33,6 +45,10 @@ pub fn init_db(
*frecency =
Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock).into_lua_result()?);
tracing::info!("Frecency database initialized at {}", frecency_db_path);
drop(frecency);
// Spawn background GC to purge stale entries without blocking startup
FrecencyTracker::spawn_gc(Arc::clone(&FRECENCY), frecency_db_path, use_unsafe_no_lock);
let mut query_tracker = QUERY_TRACKER
.write()
@@ -68,31 +84,54 @@ pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
}
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if file_picker.is_some() {
return Ok(false);
{
let guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if guard.is_some() {
return Ok(false);
}
}
let picker = FilePicker::new(base_path).into_lua_result()?;
*file_picker = Some(picker);
FilePicker::new_with_shared_state(
base_path,
false,
FFFMode::Neovim,
Arc::clone(&FILE_PICKER),
Arc::clone(&FRECENCY),
)
.into_lua_result()?;
Ok(true)
}
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)?;
// drop should clean it anyway but just to be extra sure
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
// Cancel and stop the old picker under a single write lock to avoid
// a window where FILE_PICKER is None (which causes FilePickerMissing
// errors if the UI is searching concurrently).
{
let mut guard = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)?;
if let Some(ref mut picker) = *guard {
// Signal cancellation BEFORE stopping — this tells any orphaned
// scan threads from this picker to discard their results.
picker.cancel();
picker.stop_background_monitor();
}
// Don't take() here — leave the old picker in place so searches
// still work until new_with_shared_state replaces it atomically.
}
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
*file_picker = Some(new_picker);
// Create new picker — this atomically replaces the old one via write lock
FilePicker::new_with_shared_state(
path.to_string_lossy().to_string(),
false,
FFFMode::Neovim,
Arc::clone(&FILE_PICKER),
Arc::clone(&FRECENCY),
)?;
Ok(())
}
@@ -110,6 +149,12 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
&& picker.base_path() == canonical_path
{
return Ok(()); // same dir
}
// Spawn a background thread to avoid blocking Lua/UI thread
std::thread::spawn(move || {
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
@@ -136,7 +181,7 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
.ok_or(Error::FilePickerMissing)
.into_lua_result()?;
picker.trigger_rescan().into_lua_result()?;
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
::tracing::info!("scan_files trigger_rescan completed");
Ok(())
}
@@ -205,10 +250,10 @@ pub fn fuzzy_search_files(
let parser = QueryParser::default();
let parsed = parser.parse(&query);
let files = picker.get_files();
let results = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
files,
&parsed,
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
@@ -223,6 +268,34 @@ pub fn fuzzy_search_files(
},
);
if results.items.is_empty() && query.contains(std::path::MAIN_SEPARATOR) {
let pure_query = match &parsed.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => t.trim(),
_ => query.trim(),
};
let path = expand_tilde(pure_query);
if path.is_absolute() && path.is_file() {
if let Ok(idx) = files.binary_search_by(|f| f.path.as_path().cmp(&path)) {
let found = SearchResult {
items: vec![&files[idx]],
scores: vec![Score {
exact_match: true,
match_type: "path",
..Default::default()
}],
total_matched: 1,
total_files: results.total_files,
location: parsed.location,
};
return lua_types::SearchResultLua::from(found).into_lua(lua);
}
return build_file_path_fallback(lua, &path, results.total_files);
}
}
lua_types::SearchResultLua::from(results).into_lua(lua)
}
@@ -273,13 +346,66 @@ pub fn live_grep(
page_limit: page_size.unwrap_or(50),
mode,
time_budget_ms: time_budget_ms.unwrap_or(0),
before_context: 0,
after_context: 0,
classify_definitions: false,
};
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed, &options);
let result = fff_core::grep::grep_search(picker.get_files(), &parsed, &options);
lua_types::GrepResultLua::from(result).into_lua(lua)
}
/// Build a file-picker result for an absolute path that exists on disk but
/// isn't in the picker index (e.g. file from a different project).
fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = path.to_string_lossy().to_string();
let item = lua.create_table()?;
item.set("path", path_str.as_str())?;
item.set("relative_path", path_str.as_str())?;
item.set("name", name.as_str())?;
item.set("size", path.metadata().map(|m| m.len()).unwrap_or(0))?;
item.set("modified", 0u64)?;
item.set("access_frecency_score", 0i64)?;
item.set("modification_frecency_score", 0i64)?;
item.set("total_frecency_score", 0i64)?;
item.set("git_status", "")?;
item.set("is_binary", false)?;
let items_table = lua.create_table()?;
items_table.set(1, item)?;
table.set("items", items_table)?;
let score = lua.create_table()?;
score.set("total", 0)?;
score.set("base_score", 0)?;
score.set("filename_bonus", 0)?;
score.set("special_filename_bonus", 0)?;
score.set("frecency_boost", 0)?;
score.set("git_status_boost", 0)?;
score.set("distance_penalty", 0)?;
score.set("current_file_penalty", 0)?;
score.set("combo_match_boost", 0)?;
score.set("exact_match", true)?;
score.set("match_type", "path")?;
let scores_table = lua.create_table()?;
scores_table.set(1, score)?;
table.set("scores", scores_table)?;
table.set("total_matched", 1)?;
table.set("total_files", total_files)?;
Ok(LuaValue::Table(table))
}
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
let file_path = PathBuf::from(&file_path);
@@ -362,7 +488,7 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
}
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
FilePicker::refresh_git_status_global().into_lua_result()
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
}
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
@@ -444,8 +570,9 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
};
// Spawn background thread to do the actual tracking (expensive DB write)
let query_tracker = Arc::clone(&QUERY_TRACKER);
std::thread::spawn(move || {
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
{
tracing::error!(
@@ -497,8 +624,9 @@ pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
picker.base_path().to_path_buf()
};
let query_tracker = Arc::clone(&QUERY_TRACKER);
std::thread::spawn(move || {
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
{
tracing::error!(
@@ -538,21 +666,28 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str
}
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let picker = file_picker
.as_ref()
.ok_or(Error::FilePickerMissing)
.into_lua_result()?;
// Extract the scan signal Arc WITHOUT holding the read lock, so the
// scan thread can acquire the write lock to store its results.
// Holding a read lock while polling would deadlock: the scan thread
// needs a write lock to finish, but can't acquire it while we hold the read lock.
let scan_signal = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let picker = file_picker
.as_ref()
.ok_or(Error::FilePickerMissing)
.into_lua_result()?;
picker.scan_signal()
}; // read lock released here
let timeout_ms = timeout_ms.unwrap_or(500);
let timeout_duration = Duration::from_millis(timeout_ms);
let start_time = std::time::Instant::now();
let mut sleep_duration = Duration::from_millis(1);
while picker.is_scan_active() {
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
if start_time.elapsed() >= timeout_duration {
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
return Ok(false);
@@ -772,6 +907,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
)?;
exports.set("health_check", lua.create_function(health_check)?)?;
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
exports.set("hex_dump", lua.create_function(hex_dump::hex_dump)?)?;
Ok(exports)
}
+2 -151
View File
@@ -1,152 +1,3 @@
use std::io;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
//! Logging setup for fff-nvim — delegates to the shared fff-core::log utilities.
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// Install panic hook that writes to both stderr and a fallback file
/// This is called separately from init_tracing to ensure panics are always logged
pub fn install_panic_hook() {
PANIC_HOOK_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
// Always log to tracing (if initialized)
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF.nvim"
);
// Always print to stderr
eprintln!("=== FFF.nvim PANIC ===");
eprintln!("Message: {}", message);
eprintln!("Location: {}", location);
eprintln!("======================");
// Try to write to fallback panic log file
if let Some(cache_dir) = dirs::cache_dir() {
let panic_log = cache_dir.join("fff_nvim_panic.log");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let panic_entry = format!(
"\n[{}] PANIC at {}\nMessage: {}\n",
timestamp, location, message
);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&panic_log)
.and_then(|mut f| {
use std::io::Write;
f.write_all(panic_entry.as_bytes())
});
eprintln!("Panic logged to: {}", panic_log.display());
}
default_panic(panic_info);
}));
});
}
/// Initialize tracing with single log file
///
/// # Arguments
/// * `log_file_path` - Full path to the log file
/// * `log_level` - Log level (trace, debug, info, warn, error)
///
/// # Returns
/// * `Result<String, io::Error>` - Full path to the log file on success
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
// Install panic hook first (does nothing if already installed)
install_panic_hook();
let log_path = Path::new(log_file_path);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file_appender = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true) // creates a new file on every setup
.open(log_path)?;
let level = match log_level
.as_ref()
.map(|s| s.trim().to_lowercase())
.as_deref()
{
Some("trace") => tracing::Level::TRACE,
Some("debug") => tracing::Level::DEBUG,
Some("info") => tracing::Level::INFO,
Some("warn") => tracing::Level::WARN,
Some("error") => tracing::Level::ERROR,
_ => tracing::Level::INFO,
};
TRACING_INITIALIZED.get_or_init(|| {
let (non_blocking_appender, guard) = non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
.with_default_directive(level.into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF.nvim tracing initialized with log file: {}",
log_path.display()
);
}
guard
});
Ok(log_file_path.to_string())
}
pub use fff_core::log::{init_tracing, install_panic_hook};
+1 -7
View File
@@ -1,12 +1,7 @@
//! Lua type conversions for fff-core types
//!
//! This module provides IntoLua implementations for core types.
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepResult, Location, Score, SearchResult};
use mlua::prelude::*;
/// Wrapper for SearchResult that implements IntoLua
pub struct SearchResultLua<'a> {
inner: SearchResult<'a>,
}
@@ -17,7 +12,6 @@ impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
}
}
/// Wrapper for GrepResult that implements IntoLua
pub struct GrepResultLua<'a> {
inner: GrepResult<'a>,
}
@@ -167,7 +161,7 @@ impl IntoLua for GrepResultLua<'_> {
}
table.set("items", items_table)?;
table.set("total_matched", self.inner.total_match_count)?;
table.set("total_matched", self.inner.matches.len())?;
table.set("total_files_searched", self.inner.total_files_searched)?;
table.set("total_files", self.inner.total_files)?;
table.set("filtered_file_count", self.inner.filtered_file_count)?;
+2 -2
View File
@@ -34,7 +34,7 @@ impl PathCache {
}
}
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size), level = tracing::Level::TRACE)]
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
self.map.get(path).and_then(|entry| {
// Only return cached value if max_size matches
@@ -85,7 +85,7 @@ pub fn shorten_path_with_cache(
.read()
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
if let Some(cached) = cache.get(path, max_size) {
tracing::debug!("Cache hit for path '{}'", path.display());
tracing::trace!("Cache hit for path '{}'", path.display());
return Ok(cached.to_string());
}
}
+5 -1
View File
@@ -6,9 +6,13 @@ edition = "2024"
[lib]
path = "src/lib.rs"
[features]
default = []
zlob = ["dep:zlob"]
[dependencies]
smallvec = { workspace = true }
zlob = { version = "1.2.8" }
zlob = { workspace = true, optional = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
+132 -7
View File
@@ -1,5 +1,46 @@
use crate::constraints::Constraint;
use zlob::{ZlobFlags, has_wildcards};
use crate::glob_detect::has_wildcards;
/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
///
/// A token is a filename/path if ALL of:
/// - Does NOT end with `/` (that's a directory/PathSegment)
/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
/// - Last component (after final `/`) contains `.` with a valid-looking extension
/// (110 alphanumeric chars starting with a letter, e.g. `rs`, `json`, `tsx`)
///
/// This covers both bare filenames (`score.rs`) and path-prefixed ones (`src/main.rs`).
#[inline]
fn is_filename_constraint_token(token: &str) -> bool {
let bytes = token.as_bytes();
// Must NOT end with / (that's a PathSegment)
if bytes.last() == Some(&b'/') {
return false;
}
// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}
// Get the filename component (after last /)
let filename = token.rsplit('/').next().unwrap_or(token);
// Extension must exist and look like a real file extension:
// starts with an ASCII letter (rejects version numbers like "v2.0"),
// followed by alphanumeric chars, max 10 chars total.
match filename.rfind('.') {
Some(dot_pos) => {
let ext = &filename[dot_pos + 1..];
!ext.is_empty()
&& ext.len() <= 10
&& ext.as_bytes()[0].is_ascii_alphabetic()
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
}
None => false,
}
}
/// Parser configuration trait - allows different picker types to customize parsing
pub trait ParserConfig {
@@ -32,6 +73,13 @@ pub trait ParserConfig {
true
}
/// Should parse location suffixes (e.g., file:12, file:12:4)
/// Disabled for grep modes where colon-number patterns like localhost:8080
/// are search text, not file locations.
fn enable_location(&self) -> bool {
true
}
/// Determine whether a token should be treated as a glob constraint.
///
/// The default implementation delegates to `zlob::has_wildcards` with
@@ -40,7 +88,7 @@ pub trait ParserConfig {
/// Override this in configs where some wildcard characters are common
/// in search text (e.g. grep mode where `?` and `[` appear in code).
fn is_glob_pattern(&self, token: &str) -> bool {
has_wildcards(token, ZlobFlags::RECOMMENDED)
has_wildcards(token)
}
/// Custom constraint parsers for picker-specific needs
@@ -54,7 +102,16 @@ pub trait ParserConfig {
pub struct FilePickerConfig;
impl ParserConfig for FilePickerConfig {
// All defaults enabled
/// Detect bare filenames (`score.rs`) and path-prefixed filenames (`src/main.rs`)
/// as `FilePath` constraints so that multi-token queries like `score.rs file_picker`
/// filter by filename first, then fuzzy-match the remaining text against the path.
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
/// Configuration for full-text search (grep) - file constraints enabled for
@@ -76,6 +133,10 @@ impl ParserConfig for GrepConfig {
false
}
fn enable_location(&self) -> bool {
false
}
/// Only recognise globs that are clearly directory/path oriented.
///
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
@@ -87,7 +148,7 @@ impl ParserConfig for GrepConfig {
/// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
fn is_glob_pattern(&self, token: &str) -> bool {
// Must contain at least one glob wildcard character
if !has_wildcards(token, ZlobFlags::RECOMMENDED) {
if !has_wildcards(token) {
return false;
}
@@ -98,12 +159,76 @@ impl ParserConfig for GrepConfig {
return true;
}
// Brace expansion → useful for directory alternatives
if bytes.contains(&b'{') && bytes.contains(&b'}') {
return true;
// Brace expansion → useful for directory alternatives.
// Require a comma between `{` and `}` AND at least one letter to
// distinguish real glob expansions like `{src,lib}` or `*.{ts,tsx}`
// from code patterns like `format!("{}")` and regex quantifiers `{2,3}`.
if let Some(open) = bytes.iter().position(|&b| b == b'{')
&& let Some(close) = bytes.iter().rposition(|&b| b == b'}')
{
let inner = &bytes[open + 1..close];
if inner.contains(&b',') && inner.iter().any(|b| b.is_ascii_alphabetic()) {
return true;
}
}
// Everything else (?, [, bare * without /) → treat as literal text
false
}
}
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
/// automatic file-path constraint detection.
///
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
/// so the search is scoped to matching files. The caller validates the
/// constraint against the index and drops it if no files match (fallback).
#[derive(Debug, Clone, Copy, Default)]
pub struct AiGrepConfig;
impl ParserConfig for AiGrepConfig {
fn enable_path_segments(&self) -> bool {
true
}
fn enable_git_status(&self) -> bool {
false
}
fn enable_location(&self) -> bool {
false
}
fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
return true;
}
// AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
// Recognise tokens that start AND end with `*` with non-empty text
// between them as glob constraints. Bare `*` or `**` are excluded.
if !has_wildcards(token) {
return false;
}
let bytes = token.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'*'
&& bytes[bytes.len() - 1] == b'*'
&& bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
{
return true;
}
false
}
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
@@ -22,6 +22,10 @@ pub enum Constraint<'a> {
/// Path constraint: /src/ -> PathSegment("src")
PathSegment(&'a str),
/// File path constraint (AI mode): "libswscale/input.c" → FilePath("libswscale/input.c")
/// Matches files whose relative path ends with this suffix at a `/` boundary.
FilePath(&'a str),
/// File type constraint: type:rust -> FileType("rust")
FileType(&'a str),
@@ -0,0 +1,18 @@
//! Glob wildcard detection — delegates to zlob when available, pure-Rust fallback otherwise.
//!
//! All call sites use a single function: `has_wildcards(text) -> bool`.
//! When the `zlob` feature is enabled this calls `zlob::has_wildcards` with
//! `ZlobFlags::RECOMMENDED`; without it we check for the same set of wildcard
//! characters (`*`, `?`, `[`, `{`) in pure Rust.
#[cfg(feature = "zlob")]
#[inline]
pub fn has_wildcards(s: &str) -> bool {
zlob::has_wildcards(s, zlob::ZlobFlags::RECOMMENDED)
}
#[cfg(not(feature = "zlob"))]
#[inline]
pub fn has_wildcards(s: &str) -> bool {
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
}
+24 -38
View File
@@ -13,12 +13,13 @@
//!
//! let parser = QueryParser::default();
//!
//! // Single-token queries return None (no parsing needed)
//! // Single-token queries return FFFQuery with Text fuzzy query and no constraints
//! let result = parser.parse("hello");
//! assert!(result.is_none());
//! assert!(result.constraints.is_empty());
//! assert_eq!(result.fuzzy_query, FuzzyQuery::Text("hello"));
//!
//! // Multi-token queries are parsed
//! let result = parser.parse("name *.rs").expect("Should parse");
//! let result = parser.parse("name *.rs");
//! match &result.fuzzy_query {
//! FuzzyQuery::Text(text) => assert_eq!(*text, "name"),
//! _ => panic!("Expected text"),
@@ -26,11 +27,11 @@
//! assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
//!
//! // Parse glob pattern with text
//! let result = parser.parse("**/*.rs foo").expect("Should parse");
//! let result = parser.parse("**/*.rs foo");
//! assert!(matches!(result.constraints[0], Constraint::Glob("**/*.rs")));
//!
//! // Parse negation
//! let result = parser.parse("!*.rs foo").expect("Should parse");
//! let result = parser.parse("!*.rs foo");
//! match &result.constraints[0] {
//! Constraint::Not(inner) => {
//! assert!(matches!(inner.as_ref(), Constraint::Extension("rs")));
@@ -41,10 +42,11 @@
mod config;
mod constraints;
pub mod glob_detect;
pub mod location;
mod parser;
pub use config::{FilePickerConfig, GrepConfig, ParserConfig};
pub use config::{AiGrepConfig, FilePickerConfig, GrepConfig, ParserConfig};
pub use constraints::{Constraint, GitStatusFilter};
pub use location::Location;
pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
@@ -63,32 +65,30 @@ mod tests {
fn test_empty_query() {
let parser = QueryParser::default();
let result = parser.parse("");
// Empty query returns None (single-token behavior)
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Empty);
}
#[test]
fn test_whitespace_only() {
let parser = QueryParser::default();
let result = parser.parse(" ");
// Whitespace-only returns None
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Empty);
}
#[test]
fn test_single_token() {
let parser = QueryParser::default();
let result = parser.parse("hello");
// Single token returns None (no parsing needed)
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("hello"));
}
#[test]
fn test_simple_text() {
let parser = QueryParser::default();
let result = parser
.parse("hello world")
.expect("Should parse multi-token");
let result = parser.parse("hello world");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
@@ -106,9 +106,7 @@ mod tests {
fn test_extension_only() {
let parser = QueryParser::default();
// Single constraint token - returns Some so constraint can be applied
let result = parser
.parse("*.rs")
.expect("Should parse single constraint");
let result = parser.parse("*.rs");
assert!(matches!(result.fuzzy_query, FuzzyQuery::Empty));
assert_eq!(result.constraints.len(), 1);
assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
@@ -117,9 +115,7 @@ mod tests {
#[test]
fn test_glob_pattern() {
let parser = QueryParser::default();
let result = parser
.parse("**/*.rs foo")
.expect("Should parse multi-token");
let result = parser.parse("**/*.rs foo");
assert_eq!(result.constraints.len(), 1);
// Glob patterns with ** are treated as globs, not extensions
match &result.constraints[0] {
@@ -131,7 +127,7 @@ mod tests {
#[test]
fn test_negation_pattern() {
let parser = QueryParser::default();
let result = parser.parse("!test foo").expect("Should parse multi-token");
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -144,7 +140,7 @@ mod tests {
#[test]
fn test_path_segment() {
let parser = QueryParser::default();
let result = parser.parse("/src/ foo").expect("Should parse multi-token");
let result = parser.parse("/src/ foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -155,9 +151,7 @@ mod tests {
#[test]
fn test_git_status() {
let parser = QueryParser::default();
let result = parser
.parse("status:modified foo")
.expect("Should parse multi-token");
let result = parser.parse("status:modified foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -168,9 +162,7 @@ mod tests {
#[test]
fn test_file_type() {
let parser = QueryParser::default();
let result = parser
.parse("type:rust foo")
.expect("Should parse multi-token");
let result = parser.parse("type:rust foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -181,9 +173,7 @@ mod tests {
#[test]
fn test_complex_query() {
let parser = QueryParser::default();
let result = parser
.parse("src name *.rs !test /lib/ status:modified")
.expect("Should parse");
let result = parser.parse("src name *.rs !test /lib/ status:modified");
// Verify we have fuzzy text
match &result.fuzzy_query {
@@ -225,9 +215,7 @@ mod tests {
#[test]
fn test_no_heap_allocation_for_small_queries() {
let parser = QueryParser::default();
let result = parser
.parse("*.rs *.toml !test")
.expect("Should parse multi-token");
let result = parser.parse("*.rs *.toml !test");
// SmallVec should not have spilled to heap
assert!(!result.constraints.spilled());
}
@@ -235,9 +223,7 @@ mod tests {
#[test]
fn test_many_fuzzy_parts() {
let parser = QueryParser::default();
let result = parser
.parse("one two three four five six")
.expect("Should parse");
let result = parser.parse("one two three four five six");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
+585 -79
View File
@@ -1,8 +1,8 @@
use crate::ConstraintVec;
use crate::config::ParserConfig;
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
use crate::glob_detect::has_wildcards;
use crate::location::{Location, parse_location};
use zlob::{ZlobFlags, has_wildcards};
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::large_enum_variant)]
@@ -14,6 +14,8 @@ pub enum FuzzyQuery<'a> {
#[derive(Debug, Clone, PartialEq)]
pub struct FFFQuery<'a> {
/// The original raw query string before parsing
pub raw_query: &'a str,
/// Parsed constraints (stack-allocated for ≤8 constraints)
pub constraints: ConstraintVec<'a>,
pub fuzzy_query: FuzzyQuery<'a>,
@@ -32,8 +34,8 @@ impl<C: ParserConfig> QueryParser<C> {
Self { config }
}
pub fn parse<'a>(&self, query: &'a str) -> Option<FFFQuery<'a>> {
let query: &'a str = query;
pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
let raw_query = query;
let config: &C = &self.config;
let mut constraints = ConstraintVec::new();
let query = query.trim();
@@ -44,34 +46,75 @@ impl<C: ParserConfig> QueryParser<C> {
if whitespace_count == 0 {
// Try to parse as constraint first
if let Some(constraint) = parse_token(query, config) {
constraints.push(constraint);
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Empty,
location: None,
});
// Don't treat filename tokens (FilePath) as constraints in single-token
// queries — the user is fuzzy-searching, not filtering. FilePath constraints
// are only useful as filters in multi-token queries like "score.rs search".
//
// Also skip PathSegment constraints when the token looks like an absolute
// file path with a location suffix (e.g. /Users/.../file.rs:12). Without
// this, the leading `/` causes the entire path to be consumed as a
// PathSegment, preventing location parsing from running.
let has_location_suffix = matches!(constraint, Constraint::PathSegment(_))
&& query.bytes().any(|b| b == b':')
&& query
.bytes()
.rev()
.take_while(|&b| b != b':')
.all(|b| b.is_ascii_digit());
if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {
constraints.push(constraint);
return FFFQuery {
raw_query,
constraints,
fuzzy_query: FuzzyQuery::Empty,
location: None,
};
}
}
// Try to extract location from single token (e.g., "file:12")
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
});
if config.enable_location() {
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return FFFQuery {
raw_query,
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
};
}
}
// Plain text single token - return None (caller handles as simple fuzzy match)
return None;
// Plain text single token
return FFFQuery {
raw_query,
constraints,
fuzzy_query: if query.is_empty() {
FuzzyQuery::Empty
} else {
FuzzyQuery::Text(query)
},
location: None,
};
}
// Stack-allocated buffer for text parts (up to 16 parts)
let mut text_parts = TextPartsBuffer::new();
let tokens = query.split_whitespace();
let mut has_file_path = false;
for token in tokens {
match parse_token(token, config) {
Some(Constraint::FilePath(_)) => {
if has_file_path {
// Only one FilePath constraint allowed; treat extra path
// tokens as literal text (e.g. an import path the user is
// searching for).
text_parts.push(token);
} else {
constraints.push(Constraint::FilePath(token));
has_file_path = true;
}
}
Some(constraint) => {
constraints.push(constraint);
}
@@ -83,7 +126,7 @@ impl<C: ParserConfig> QueryParser<C> {
// Try to extract location from the last fuzzy token
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
let location = if !text_parts.is_empty() {
let location = if config.enable_location() && !text_parts.is_empty() {
let last_idx = text_parts.len() - 1;
let (without_loc, loc) = parse_location(text_parts[last_idx]);
if loc.is_some() {
@@ -115,14 +158,56 @@ impl<C: ParserConfig> QueryParser<C> {
}
};
Some(FFFQuery {
FFFQuery {
raw_query,
constraints,
fuzzy_query,
location,
})
}
}
}
impl<'a> FFFQuery<'a> {
/// Returns the grep search text by joining all non-constraint text tokens.
///
/// Backslash-escaped tokens (e.g. `\*.rs`) are included as literal text
/// with the leading `\` stripped, since the backslash is only an escape
/// signal to the parser and should not appear in the final pattern.
///
/// `FuzzyQuery::Empty` → empty string
/// `FuzzyQuery::Text("foo")` → `"foo"`
/// `FuzzyQuery::Parts(["a", "\\*.rs", "b"])` → `"a *.rs b"`
pub fn grep_text(&self) -> String {
match &self.fuzzy_query {
FuzzyQuery::Empty => String::new(),
FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
FuzzyQuery::Parts(parts) => parts
.iter()
.map(|t| strip_leading_backslash(t))
.collect::<Vec<_>>()
.join(" "),
}
}
}
/// Strip the leading `\` from a backslash-escaped constraint token only.
///
/// We strip the backslash when the next character is a constraint trigger
/// (`*`, `/`, `!`) — the user typed `\*.rs` to mean literal `*.rs`, not an
/// extension constraint. For regex escape sequences like `\w`, `\b`, `\d`,
/// `\s`, `\n` etc., the backslash is preserved so regex mode works correctly.
#[inline]
fn strip_leading_backslash(token: &str) -> &str {
if token.len() > 1 && token.starts_with('\\') {
let next = token.as_bytes()[1];
// Only strip if the backslash is escaping a constraint trigger character
if next == b'*' || next == b'/' || next == b'!' {
return &token[1..];
}
}
token
}
impl Default for QueryParser<crate::FilePickerConfig> {
fn default() -> Self {
Self::new(crate::FilePickerConfig)
@@ -151,7 +236,7 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
// Only return Extension if the rest doesn't have wildcards
// e.g., *.rs is Extension, but *.test.* should be Glob
let ext_part = &token[2..];
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
if !has_wildcards(ext_part) {
return Some(constraint);
}
}
@@ -250,7 +335,7 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
// Try extension first (*.rs) - simple patterns without additional wildcards
if let Some(constraint) = parse_extension(token) {
let ext_part = &token[2..];
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
if !has_wildcards(ext_part) {
return Some(constraint);
}
}
@@ -308,11 +393,12 @@ fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
}
/// Parse path segment with trailing slash: www/ -> PathSegment("www")
/// Also supports multi-segment paths: libswscale/aarch64/ -> PathSegment("libswscale/aarch64")
#[inline]
fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
if token.len() > 1 && token.ends_with('/') {
let segment = token.trim_end_matches('/');
if !segment.is_empty() && !segment.contains('/') {
if !segment.is_empty() {
Some(Constraint::PathSegment(segment))
} else {
None
@@ -351,7 +437,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::FilePickerConfig;
use crate::{FilePickerConfig, GrepConfig};
#[test]
fn test_parse_extension() {
@@ -395,8 +481,15 @@ mod tests {
parse_path_segment_trailing("src/"),
Some(Constraint::PathSegment("src"))
);
// Should not match paths with multiple segments
assert_eq!(parse_path_segment_trailing("src/lib/"), None);
// Multi-segment paths should work
assert_eq!(
parse_path_segment_trailing("src/lib/"),
Some(Constraint::PathSegment("src/lib"))
);
assert_eq!(
parse_path_segment_trailing("libswscale/aarch64/"),
Some(Constraint::PathSegment("libswscale/aarch64"))
);
// Should not match without trailing slash
assert_eq!(parse_path_segment_trailing("www"), None);
}
@@ -404,9 +497,7 @@ mod tests {
#[test]
fn test_trailing_slash_in_query() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("www/ test")
.expect("Should parse multi-token query");
let result = parser.parse("www/ test");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -443,9 +534,7 @@ mod tests {
fn test_negation_text() {
let parser = QueryParser::new(FilePickerConfig);
// Need two tokens for parsing to return Some
let result = parser
.parse("!test foo")
.expect("Should parse multi-token query");
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -458,9 +547,7 @@ mod tests {
#[test]
fn test_negation_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!*.rs foo")
.expect("Should parse multi-token query");
let result = parser.parse("!*.rs foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -473,9 +560,7 @@ mod tests {
#[test]
fn test_negation_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!/src/ foo")
.expect("Should parse multi-token query");
let result = parser.parse("!/src/ foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -488,9 +573,7 @@ mod tests {
#[test]
fn test_negation_git_status() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!status:modified foo")
.expect("Should parse multi-token query");
let result = parser.parse("!status:modified foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -506,9 +589,7 @@ mod tests {
#[test]
fn test_backslash_escape_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\*.rs foo")
.expect("Should parse multi-token query");
let result = parser.parse("\\*.rs foo");
// \*.rs should NOT be parsed as an Extension constraint
assert_eq!(result.constraints.len(), 0);
// Both tokens should be text
@@ -525,9 +606,7 @@ mod tests {
#[test]
fn test_backslash_escape_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\/src/ foo")
.expect("Should parse multi-token query");
let result = parser.parse("\\/src/ foo");
assert_eq!(result.constraints.len(), 0);
match result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
@@ -541,38 +620,98 @@ mod tests {
#[test]
fn test_backslash_escape_negation() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\!test foo")
.expect("Should parse multi-token query");
let result = parser.parse("\\!test foo");
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_grep_text_plain_text() {
// Multi-token plain text — no constraints
let q = QueryParser::new(GrepConfig).parse("name =");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_strips_constraint() {
let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
assert_eq!(q.grep_text(), "name = someth");
}
#[test]
fn test_grep_text_leading_constraint() {
let q = QueryParser::new(GrepConfig).parse("*.rs name =");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_only_constraints() {
let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
assert_eq!(q.grep_text(), "");
}
#[test]
fn test_grep_text_path_constraint() {
let q = QueryParser::new(GrepConfig).parse("name /src/ value");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_negation_constraint() {
let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_backslash_escape_stripped() {
// \*.rs should be text with the leading \ removed
let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
assert_eq!(q.grep_text(), "*.rs foo");
let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
assert_eq!(q.grep_text(), "/src/ foo");
let q = QueryParser::new(GrepConfig).parse("\\!test foo");
assert_eq!(q.grep_text(), "!test foo");
}
#[test]
fn test_grep_text_question_mark_is_text() {
let q = QueryParser::new(GrepConfig).parse("foo? bar");
assert_eq!(q.grep_text(), "foo? bar");
}
#[test]
fn test_grep_text_bracket_is_text() {
let q = QueryParser::new(GrepConfig).parse("arr[0] more");
assert_eq!(q.grep_text(), "arr[0] more");
}
#[test]
fn test_grep_text_path_glob_is_constraint() {
let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
assert_eq!(q.grep_text(), "pattern");
}
#[test]
fn test_grep_question_mark_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
// Single token "foo?" should return None (treated as plain text by caller)
let result = parser.parse("foo?");
assert!(result.is_none(), "foo? should be plain text in grep mode");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
}
#[test]
fn test_grep_bracket_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser.parse("arr[0] something");
let result = result.expect("Should parse multi-token query");
// arr[0] should NOT be a glob in grep mode
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_grep_path_glob_is_constraint() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern src/**/*.rs")
.expect("Should parse with path glob");
let result = parser.parse("pattern src/**/*.rs");
// src/**/*.rs contains / so it should be treated as a glob
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
@@ -583,11 +722,8 @@ mod tests {
#[test]
fn test_grep_brace_is_constraint() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern {src,lib}")
.expect("Should parse with brace expansion");
let result = parser.parse("pattern {src,lib}");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -595,13 +731,51 @@ mod tests {
));
}
#[test]
fn test_grep_text_preserves_backslash_escapes() {
// Regex patterns like \w+ and \bfoo\b must survive grep_text()
// The parser sees \w+ as a text token (not a constraint escape),
// but strip_leading_backslash was stripping the \ anyway.
let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
assert_eq!(
q.grep_text(),
"pub struct \\w+",
"Backslash-w in regex must be preserved"
);
let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
assert_eq!(
q.grep_text(),
"\\bword\\b more",
"Backslash-b word boundaries must be preserved"
);
// Single-token regex like "fn\\s+\\w+" returns FFFQuery with Text fuzzy query
let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
// But the escaped constraint forms SHOULD still be stripped:
let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
assert_eq!(
q.grep_text(),
"*.rs foo",
"Escaped constraint \\*.rs should still have backslash stripped"
);
let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
assert_eq!(
q.grep_text(),
"/src/ foo",
"Escaped constraint \\/src/ should still have backslash stripped"
);
}
#[test]
fn test_grep_bare_star_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
// "a*b" contains * but no / or {} — should be text in grep mode
let result = parser.parse("a*b something");
let result = result.expect("Should parse");
assert_eq!(
result.constraints.len(),
0,
@@ -611,11 +785,8 @@ mod tests {
#[test]
fn test_grep_negated_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !test")
.expect("Should parse negated text in grep mode");
let result = parser.parse("pattern !test");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -631,11 +802,8 @@ mod tests {
#[test]
fn test_grep_negated_path_segment() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !/src/")
.expect("Should parse negated path segment in grep mode");
let result = parser.parse("pattern !/src/");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -651,11 +819,8 @@ mod tests {
#[test]
fn test_grep_negated_extension() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !*.rs")
.expect("Should parse negated extension in grep mode");
let result = parser.parse("pattern !*.rs");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -668,4 +833,345 @@ mod tests {
other => panic!("Expected Not constraint, got {:?}", other),
}
}
// ── AI grep config tests ──────────────────────────────────────────
#[test]
fn test_ai_grep_detects_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("libswscale/input.c rgba32ToY");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
result.constraints[0],
Constraint::FilePath("libswscale/input.c")
),
"Expected FilePath, got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "rgba32ToY");
}
#[test]
fn test_ai_grep_detects_nested_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/main.rs fn main");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("src/main.rs")
));
assert_eq!(result.grep_text(), "fn main");
}
#[test]
fn test_ai_grep_no_false_positive_trailing_slash() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/ pattern");
// Should be PathSegment, NOT FilePath
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::PathSegment("src")),
"Expected PathSegment, got {:?}",
result.constraints[0]
);
}
#[test]
fn test_ai_grep_bare_filename_is_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("main.rs pattern");
// Bare filename with valid extension → FilePath constraint
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
"Expected FilePath, got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "pattern");
}
#[test]
fn test_ai_grep_bare_filename_schema_rs() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("schema.rs part_revisions");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
"Expected FilePath(schema.rs), got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "part_revisions");
}
#[test]
fn test_ai_grep_bare_word_no_extension_not_constraint() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("schema pattern");
// No extension → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "schema pattern");
}
#[test]
fn test_ai_grep_no_false_positive_no_extension() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/utils pattern");
// No extension in last component → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "src/utils pattern");
}
#[test]
fn test_ai_grep_wildcard_not_filepath() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/**/*.rs pattern");
// Contains wildcards → should be a Glob, not FilePath
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
"Expected Glob, got {:?}",
result.constraints[0]
);
}
#[test]
fn test_ai_grep_star_text_star_is_glob() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("*quote* TODO");
// `*quote*` should be recognised as a glob constraint in AI mode
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::Glob("*quote*")),
"Expected Glob(*quote*), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
}
#[test]
fn test_ai_grep_bare_star_not_glob() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("* pattern");
// Bare `*` should NOT be treated as a glob (too broad)
assert!(
result.constraints.is_empty(),
"Expected no constraints, got {:?}",
result.constraints
);
}
#[test]
fn test_grep_no_location_parsing_single_token() {
let parser = QueryParser::new(GrepConfig);
// localhost:8080 should NOT be parsed as location -- it's a search pattern
let result = parser.parse("localhost:8080");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
}
#[test]
fn test_grep_no_location_parsing_multi_token() {
let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
assert_eq!(
q.grep_text(),
"localhost:8080",
"Colon-number suffix should be preserved in grep text"
);
assert!(
q.location.is_none(),
"Grep should not parse location from colon-number"
);
}
#[test]
fn test_grep_braces_without_comma_is_text() {
let parser = QueryParser::new(GrepConfig);
// Code patterns like format!("{}") should NOT be treated as brace expansion
let result = parser.parse(r#"format!("{}\\AppData", home)"#);
assert!(
result.constraints.is_empty(),
"Braces without comma should be text, got {:?}",
result.constraints
);
assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
}
#[test]
fn test_grep_format_braces_not_glob() {
let parser = QueryParser::new(GrepConfig);
// Code like format!("{}\\path", var) must not have tokens eaten as glob constraints.
// The trailing comma on the first token means both { } and , are present,
// but the comma is outside the braces so it should NOT trigger brace expansion.
let input = "format!(\"{}\\\\AppData\", home)";
let result = parser.parse(input);
assert!(
result.constraints.is_empty(),
"format! pattern should have no constraints, got {:?}",
result.constraints
);
}
#[test]
fn test_grep_config_star_text_star_not_glob() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser.parse("*quote* TODO");
// Regular grep mode should NOT treat `*quote*` as a glob
assert!(
result.constraints.is_empty(),
"Expected no constraints in GrepConfig, got {:?}",
result.constraints
);
}
// ── File picker filename constraint tests ─────────────────────────
#[test]
fn test_file_picker_bare_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse("score.rs file_picker");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("score.rs")),
"Expected FilePath(\"score.rs\"), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
}
#[test]
fn test_file_picker_path_prefixed_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse("libswscale/slice.c lum_convert");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
result.constraints[0],
Constraint::FilePath("libswscale/slice.c")
),
"Expected FilePath(\"libswscale/slice.c\"), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
}
#[test]
fn test_file_picker_single_token_filename_stays_fuzzy() {
let parser = QueryParser::new(FilePickerConfig);
// Single-token filename should NOT become a constraint -- it should
// return FFFQuery with Text fuzzy query so the caller uses it for fuzzy matching.
let result = parser.parse("score.rs");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
}
#[test]
fn test_absolute_path_with_location_not_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
// Absolute file path with :line should parse as text + location,
// NOT as a PathSegment constraint (which would eat the whole token).
let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
assert!(
result.constraints.is_empty(),
"Absolute path with location should not become a constraint, got {:?}",
result.constraints
);
assert_eq!(
result.fuzzy_query,
FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
);
assert_eq!(result.location, Some(Location::Line(12)));
}
#[test]
fn test_file_picker_filename_with_multiple_fuzzy_parts() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse("main.rs src components");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("main.rs")
));
assert_eq!(
result.fuzzy_query,
FuzzyQuery::Parts(smallvec::smallvec!["src", "components"])
);
}
#[test]
fn test_file_picker_version_number_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse("v2.0 release");
// v2.0 extension starts with digit → not a filename constraint
assert!(
result.constraints.is_empty(),
"v2.0 should not be a FilePath constraint, got {:?}",
result.constraints
);
}
#[test]
fn test_file_picker_only_one_filepath_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse("main.rs score.rs");
// Only first filename becomes a constraint; second is text
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("main.rs")
));
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
}
#[test]
fn test_file_picker_filename_with_extension_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse("main.rs *.lua");
// main.rs → FilePath, *.lua → Extension
assert_eq!(result.constraints.len(), 2);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("main.rs")
));
assert!(matches!(
result.constraints[1],
Constraint::Extension("lua")
));
}
#[test]
fn test_file_picker_dotfile_is_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse(".gitignore src");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
"Expected FilePath(\".gitignore\"), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
}
#[test]
fn test_file_picker_no_extension_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser.parse("Makefile src");
// No dot → not a filename constraint
assert!(
result.constraints.is_empty(),
"Makefile should not be a FilePath constraint, got {:?}",
result.constraints
);
}
}
+1 -6
View File
@@ -120,12 +120,7 @@ impl Searcher {
/// Execute a search over the given slice and write the results to the
/// given sink.
pub fn search_slice<M, S>(
&mut self,
matcher: M,
slice: &[u8],
write_to: S,
) -> Result<(), S::Error>
pub fn search_slice<M, S>(&self, matcher: M, slice: &[u8], write_to: S) -> Result<(), S::Error>
where
M: Matcher,
S: Sink,
+120 -93
View File
@@ -1,52 +1,68 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 17
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 17
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
- Features |fff.nvim-features|
- Installation |fff.nvim-installation|
FFF.nvimFinally a smart fuzzy file picker for neovim.
- MCP |fff.nvim-mcp|
- Neovim guide |fff.nvim-neovim-guide|
1. Links |fff.nvim-links|
FFFAI agents (MCP)   |   Neovim usersA fast file search for your AI and neovim, with memory built-in
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
opinionated fuzzy file picker for neovim. Just for files, but well try to
solve file picking completely.
------------------------------------------------------------------------------
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
opinionated fuzzy file picker for your AI agent and Neovim. Just for file
search, but we do the file search really fff well.
It comes with a dedicated rust backend runtime that keep tracks of the file
index, your file access and modifications, git status, and provides a
comprehensive typo-resistant fuzzy search experience.
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping
with a strong focus on performance and useful search results. For humans -
provides an unbelievable typo-resistant experience, for AI agents - implements
the fastest file search with additional free memory suggesting the best search
results based on various factors like frecency, git status, file size,
definition matches, and more.
FEATURES *fff.nvim-features*
MCP *fff.nvim-mcp*
- Works out of the box with no additional configuration
- Typo resistant fuzzy search <https://github.com/saghen/frizbee>
- Git status integration allowing to take advantage of last modified times within a worktree
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
- Display images in previews (for now requires snacks.nvim)
- Smart in a plenty of different ways hopefully helpful for your workflow
- This plugin initializes itself lazily by default
FFF is an amazing way to reduce the time and tokens by giving your AI agent a
bit of memory built-in to their file search tools. It makes your AI harness to
find the code faster and spend less tokens by doing less roundtrips and reading
less useless files.
You can install FFF as a dependency for your AI agent using a simple bash
script:
>bash
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
<
INSTALLATION *fff.nvim-installation*
The installation script is here ./install-mcp.sh <./install-mcp.sh> if you want
to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`,
`Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to
"use fff". Here is an example addition to `CLAUDE.md` that works perfectly:
>sh
# CLAUDE.md
For any file search or grep in the current git indexed directory use fff tools
<
[!NOTE] Although well try to make sure to keep 100% backward compatibility,
by using you should understand that silly bugs and breaking changes may happen.
And also we hope for your contributions and feedback to make this plugin ideal
for everyone.
NEOVIM GUIDE *fff.nvim-neovim-guide*
PREREQUISITES ~
Here is some demo on the linux repository (100k files, 8GB) but you better fill
it yourself and see the magic
FFF.nvim requires:
- Neovim 0.10.0+
- Rustup <https://rustup.rs/> (we require nightly for building the native backend rustup will handle toolchain automatically)
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
INSTALLATION ~
FFF.nvim requires neovim 0.10.0 or higher
LAZY.NVIM
@@ -83,12 +99,17 @@ LAZY.NVIM
{
"fz",
function() require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' }
}
grep = {
modes = { 'fuzzy', 'plain' }
}
}) end,
desc = 'Live fffuzy grep',
}
},
{
"fc",
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
desc = 'Search current word',
},
}
}
<
@@ -144,6 +165,10 @@ all available options:
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
flex = { -- set to false to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
show_scrollbar = true, -- Show scrollbar for pagination
-- How to shorten long directory paths in the file list:
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
@@ -159,6 +184,7 @@ all available options:
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
@@ -178,27 +204,29 @@ all available options:
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
cycle_grep_modes = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
scrollbar = 'Comment',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
@@ -227,9 +255,10 @@ all available options:
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
},
-- Store file open frecency
frecency = {
@@ -240,32 +269,35 @@ all available options:
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
enabled = false, -- Show file info panel in preview
show_scores = false, -- Show scores inline in the UI
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- Live grep search configuration
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
}
})
},
})
<
@@ -275,20 +307,21 @@ KEY FEATURES ~
AVAILABLE METHODS
>lua
require('fff').find_files() -- Find files in current directory
require('fff').find_in_git_root() -- Find files in the current git repository
require('fff').find_files() -- Find files in current repository
require('fff').scan_files() -- Trigger rescan of files in the current directory
require('fff').refresh_git_status() -- Refresh git status for the active file lock
require('fff').refresh_git_status() -- Refresh git status for the active file list
require('fff').find_files_in_dir(path) -- Find files in a specific directory
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
<
just jump to the definition and see what other APIs are exposed we have a
plenty
COMMANDS
FFF.nvim provides several commands for interacting with the file picker:
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
- `:FFFScan` - Manually trigger a rescan of files in the current directory
- `:FFFRefreshGit` - Manually refresh git status for all files
- `:FFFClearCache [all|frecency|files]` - Clear various caches
@@ -297,13 +330,6 @@ FFF.nvim provides several commands for interacting with the file picker:
- `:FFFOpenLog` - Open the FFF log file in a new tab
MULTILINE PASTE SUPPORT
The input field automatically handles multiline clipboard content by joining
all lines into a single search query. This is particularly useful when copying
file paths from terminal output.
DEBUG MODE
Toggle scoring information display:
@@ -362,12 +388,43 @@ your configuration, or per-call when invoking `live_grep()`.
modes = { 'fuzzy' },
}
})
-- Pre-fill the search with an initial query
require('fff').live_grep({ query = 'search term' })
<
When only one mode is configured, the mode indicator is hidden completely and
the cycle keybind does nothing.
CONSTRAINTS
There are a number of constraints you can use to refine your search in both
grep and file search mode:
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
- `test/` - any deeply nested children of any test/ dir
- `!something` - exclude results matching something
- `!test/`, `!git:modified` - combining with any other constraint works as negation
- `./**/*.{rs,lua}` - any valid glob expression via the fastest globbing library <https://github.com/dmtrKovalenko/zlob>
For grep only:
- `*.md`, `*.{c,h}` - extension filtering
- `src/main.rs` - grep in a single file
In addition to that, all constraints can be combined together like:
>
git:modified src/**/*.rs !src/**/mod.rs user controller
<
This will find all the files that qualify the constraints and:
- match **both** user and controller (for file mode)
- match "user controller" (for grep mode)
CROSS-MODE SUGGESTIONS
When a search returns no results, FFF automatically queries the opposite search
@@ -477,47 +534,17 @@ VIEWING LOGS
If you encounter issues, check the log file:
>vim
>
:FFFOpenLog
<
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
location).
==============================================================================
1. Links *fff.nvim-links*
COMMON ISSUES
**File picker not initializing:**
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
- Check that your Neovim version is 0.10.0 or higher
**Image previews not working:**
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
- If using snacks.nvim, ensure its properly configured
**Performance issues:**
- Adjust `max_threads` in configuration based on your system
- Reduce `preview.max_lines` and `preview.max_size` for large files
- Clear cache if it becomes too large: `:FFFClearCache all`
**Files not being indexed:**
- Run `:FFFScan` to manually trigger a file scan
- Check that the `base_path` is correctly set
- Verify you have read permissions for the directory
DEBUG MODE
Enable debug mode to see scoring information and troubleshoot search results:
- Press `F2` while in the picker
- Run `:FFFDebug on` to enable permanently
- Set `debug.show_scores = true` in configuration
1. *Chart showing the superiority of fff.nvim over builtin claude code tools*: ./chart.png
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1767744144,
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
"lastModified": 1773189535,
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
"owner": "ipetkov",
"repo": "crane",
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"lastModified": 1773597492,
"narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1770865833,
"narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=",
"lastModified": 1773716879,
"narHash": "sha256-vXCTasEzzTTd0ZGEuyle20H2hjRom66JeNr7i2ktHD0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85",
"rev": "1a9ddeb45c5751b800331363703641b84d1f41f0",
"type": "github"
},
"original": {
+4
View File
@@ -35,9 +35,13 @@
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
cargoToml = builtins.fromTOML (builtins.readFile ./crates/fff-nvim/Cargo.toml);
# Common arguments can be set here to avoid repeating them later
# Note: changes here will rebuild all dependency crates
commonArgs = {
pname = cargoToml.package.name;
version = cargoToml.package.version;
src = craneLib.cleanCargoSource ./.;
strictDeps = true;
Executable
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env bash
set -eo pipefail
# FFF MCP Server installer
# Usage: curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
REPO="dmtrKovalenko/fff.nvim"
BINARY_NAME="fff-mcp"
INSTALL_DIR="${FFF_MCP_INSTALL_DIR:-$HOME/.local/bin}"
info() { printf '\033[1;34m%s\033[0m\n' "$*"; }
success() { printf '\033[1;38;5;208m%s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m%s\033[0m\n' "$*"; }
error() { printf '\033[1;31mError: %s\033[0m\n' "$*" >&2; exit 1; }
# Print JSON with syntax highlighting via jq if available, plain otherwise
print_json() {
if command -v jq &>/dev/null; then
echo "$1" | jq .
else
echo "$1"
fi
}
detect_platform() {
local os arch target
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux)
# Prefer musl (static) for maximum compatibility
case "$arch" in
x86_64) target="x86_64-unknown-linux-musl" ;;
aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
*) error "Unsupported architecture: $arch" ;;
esac
;;
Darwin)
case "$arch" in
x86_64) target="x86_64-apple-darwin" ;;
aarch64|arm64) target="aarch64-apple-darwin" ;;
*) error "Unsupported architecture: $arch" ;;
esac
;;
MINGW*|MSYS*|CYGWIN*)
case "$arch" in
x86_64) target="x86_64-pc-windows-msvc" ;;
aarch64|arm64) target="aarch64-pc-windows-msvc" ;;
*) error "Unsupported architecture: $arch" ;;
esac
;;
*) error "Unsupported OS: $os" ;;
esac
echo "$target"
}
get_latest_release_tag() {
local target="$1"
local releases_json
releases_json=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases") \
|| error "Failed to fetch releases from https://github.com/${REPO}/releases"
# Find the first release that contains an fff-mcp binary for our platform
local tag
tag=$(echo "$releases_json" \
| grep -oE '"(tag_name|name)": *"[^"]*"' \
| awk -v target="fff-mcp-${target}" '
/"tag_name":/ { gsub(/.*": *"|"/, ""); current_tag = $0; next }
/"name":/ && index($0, target) { print current_tag; exit }
')
if [ -z "$tag" ]; then
error "No release found containing fff-mcp binaries for ${target}. The MCP build may not have been released yet."
fi
echo "$tag"
}
download_binary() {
local target="$1"
local tag="$2"
local ext=""
case "$target" in
*windows*) ext=".exe" ;;
esac
local filename="${BINARY_NAME}-${target}${ext}"
local url="https://github.com/${REPO}/releases/download/${tag}/${filename}"
local checksum_url="${url}.sha256"
info "Downloading ${filename} from release ${tag}..."
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url" 2>/dev/null; then
echo "" >&2
printf '\033[1;31mError: Failed to download binary for your platform.\033[0m\n' >&2
echo "" >&2
echo " URL: ${url}" >&2
echo " Release: ${tag}" >&2
echo " Platform: ${target}" >&2
echo "" >&2
echo "This likely means the MCP binary hasn't been built for this release yet." >&2
echo "Check available releases at: https://github.com/${REPO}/releases" >&2
exit 1
fi
# Verify checksum if sha256sum is available
if command -v sha256sum &>/dev/null; then
if curl -fsSL -o "${tmp_dir}/${filename}.sha256" "$checksum_url" 2>/dev/null; then
info "Verifying checksum..."
(cd "$tmp_dir" && sha256sum -c "${filename}.sha256") \
|| error "Checksum verification failed!"
else
warn "Checksum file not available, skipping verification."
fi
fi
# Install
mkdir -p "$INSTALL_DIR"
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
if [ "$IS_UPDATE" != true ]; then
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
fi
}
check_path() {
case ":$PATH:" in
*":${INSTALL_DIR}:"*) return 0 ;;
esac
warn "${INSTALL_DIR} is not in your PATH."
echo ""
echo "Add it to your shell profile:"
echo ""
local shell_name
shell_name="$(basename "${SHELL:-bash}")"
case "$shell_name" in
zsh)
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc"
echo " source ~/.zshrc"
;;
fish)
echo " fish_add_path ${INSTALL_DIR}"
;;
*)
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc"
echo " source ~/.bashrc"
;;
esac
echo ""
}
print_setup_instructions() {
local binary_path="${INSTALL_DIR}/${BINARY_NAME}"
local found_any=false
echo ""
success "FFF MCP Server installed successfully!"
echo ""
info "Setup with your AI coding assistant:"
echo ""
# Claude Code
if command -v claude &>/dev/null; then
found_any=true
success "[Claude Code] detected"
echo ""
echo "Global (recommended):"
echo "claude mcp add -s user fff -- ${binary_path}"
echo ""
echo "Or project-level .mcp.json (uses PATH):"
echo ""
print_json '{
"mcpServers": {
"fff": {
"type": "stdio",
"command": "fff-mcp",
"args": []
}
}
}'
echo ""
fi
# OpenCode
if command -v opencode &>/dev/null; then
found_any=true
success "[OpenCode] detected"
echo ""
echo "Add to ~/.config/opencode/opencode.json:"
echo ""
print_json '{
"mcp": {
"fff": {
"type": "local",
"command": ["fff-mcp"],
"enabled": true
}
}
}'
echo ""
fi
# Codex
if command -v codex &>/dev/null; then
found_any=true
success "[Codex] detected"
echo ""
echo "codex mcp add fff -- fff-mcp"
echo ""
fi
if [ "$found_any" = false ]; then
echo "No AI coding assistants detected."
echo ""
echo "Binary path: ${binary_path}"
echo ""
fi
echo "Binary: ${binary_path}"
echo "Docs: https://github.com/${REPO}"
echo ""
info "Tip: Add this to your CLAUDE.md or AGENTS.md to make AI use fff for all searches:"
echo "\""
echo "Use the fff MCP tools for all file search operations instead of default tools."
echo "\""
}
main() {
local target
target="$(detect_platform)"
local existing_binary="${INSTALL_DIR}/${BINARY_NAME}"
IS_UPDATE=false
if [ -x "$existing_binary" ]; then
IS_UPDATE=true
info "Updating FFF MCP Server..."
else
info "Installing FFF MCP Server..."
fi
echo ""
info "Detected platform: ${target}"
local tag
tag="$(get_latest_release_tag "$target")"
download_binary "$target" "$tag"
if [ "$IS_UPDATE" = true ]; then
echo ""
success "FFF MCP Server updated to ${tag}!"
echo ""
else
check_path
print_setup_instructions
fi
}
main
+11 -10
View File
@@ -41,7 +41,7 @@ local function detect_combo_item(items, file_picker, combo_boost_score_multiplie
end
local function create_header_text(combo_count, win_width, disable_combo_display)
local combo_text = nil
local combo_text
if disable_combo_display then
combo_text = LAST_MATCH_TEXT_FORMAT
else
@@ -57,32 +57,32 @@ end
local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl)
local config = require('fff.conf').get()
vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1)
vim.api.nvim_buf_add_highlight(
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, { end_row = line_idx, end_col = 0, hl_group = border_hl })
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
config.hl.combo_header,
line_idx - 1,
LEFT_HEADER_PADDING,
LEFT_HEADER_PADDING + text_len
{ end_col = LEFT_HEADER_PADDING + text_len, hl_group = config.hl.combo_header }
)
end
local function get_or_create_overlay_buf(state_key)
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
---@diagnostic disable-next-line: assign-type-mismatch
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = overlay_state[state_key] })
end
return overlay_state[state_key]
end
local function update_overlay_content(buf, content, border_hl)
-- Batch all buffer operations together for performance
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
vim.api.nvim_buf_set_extmark(buf, overlay_state.ns_id, 0, 0, { end_row = 1, end_col = 0, hl_group = border_hl })
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
end
local function position_overlay_window(state_key, buf, width, row, col)
@@ -101,10 +101,11 @@ local function position_overlay_window(state_key, buf, width, row, col)
if overlay_state[state_key] and vim.api.nvim_win_is_valid(overlay_state[state_key]) then
vim.api.nvim_win_set_config(overlay_state[state_key], win_config)
else
---@diagnostic disable-next-line: assign-type-mismatch
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
end
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
vim.api.nvim_set_option_value('winhighlight', 'Normal:Normal', { win = overlay_state[state_key] })
end
local function update_overlays(list_win, combo_header_line, border_hl)
+125 -16
View File
@@ -1,8 +1,82 @@
local M = {}
--@class fff.conf.State
--- @class FffLayoutConfig
--- @field height number
--- @field width number
--- @field prompt_position string
--- @field preview_position string
--- @field preview_size number
--- @field show_scrollbar boolean
--- @field path_shorten_strategy string
--- @class FffPreviewConfig
--- @field enabled boolean
--- @field max_size number
--- @field chunk_size number
--- @field binary_file_threshold number
--- @field imagemagick_info_format_str string
--- @field line_numbers boolean
--- @field cursorlineopt string
--- @field wrap_lines boolean
--- @field filetypes table<string, table>
--- @class FffKeymapsConfig
--- @field close string
--- @field select string
--- @field select_split string
--- @field select_vsplit string
--- @field select_tab string
--- @field move_up string|string[]
--- @field move_down string|string[]
--- @field preview_scroll_up string
--- @field preview_scroll_down string
--- @field toggle_debug string
--- @field cycle_grep_modes string
--- @field cycle_previous_query string
--- @field toggle_select string
--- @field send_to_quickfix string
--- @field focus_list string
--- @field focus_preview string
--- @class FffFrecencyConfig
--- @field enabled boolean
--- @field db_path string
--- @class FffHistoryConfig
--- @field enabled boolean
--- @field db_path string
--- @field min_combo_count number
--- @field combo_boost_score_multiplier number
--- @class FffGrepConfig
--- @field max_file_size number
--- @field max_matches_per_file number
--- @field smart_case boolean
--- @field time_budget_ms number
--- @field modes string[]
--- @class FffConfig
--- @field base_path string
--- @field prompt string
--- @field title string
--- @field max_results number
--- @field max_threads number
--- @field lazy_sync boolean
--- @field layout FffLayoutConfig
--- @field preview FffPreviewConfig
--- @field keymaps FffKeymapsConfig
--- @field hl table<string, string>
--- @field frecency FffFrecencyConfig
--- @field history FffHistoryConfig
--- @field git table
--- @field debug table
--- @field logging table
--- @field file_picker table
--- @field grep FffGrepConfig
---@class fff.conf.State
local state = {
---@type table | nil
---@type FffConfig|nil
config = nil,
}
@@ -99,6 +173,19 @@ local function handle_deprecated_config(user_config)
return migrated_config
end
---@param name table list of highlight groups to choose from
---@return string one of the provided groups
local function fallback_hl(name)
local resolved_hl
for _, hl in ipairs(name) do
local resolved_group = vim.api.nvim_get_hl(0, { name = hl })
if not vim.tbl_isempty(resolved_group) then resolved_hl = hl end
end
return resolved_hl or name[#name]
end
local function init()
local config = vim.g.fff or {}
local default_config = {
@@ -107,14 +194,24 @@ local function init()
title = 'FFFiles',
max_results = 100,
max_threads = 4,
lazy_sync = true, -- set to false if you want file indexing to start on open
layout = {
height = 0.8,
width = 0.8,
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
flex = { -- set to nil to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
show_scrollbar = true, -- Show scrollbar for pagination
path_shorten_strategy = 'middle_number', -- or 'middle', 'end'
-- How to shorten long directory paths in the file list:
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
-- and numbers for 4+ (a/.4./b, a/.5./b)
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
-- 'end': truncates from the end (home/user/projects)
path_shorten_strategy = 'middle_number',
},
preview = {
enabled = true,
@@ -123,6 +220,7 @@ local function init()
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both',
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
@@ -136,31 +234,35 @@ local function init()
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
-- you can assign multiple keys to any action
move_up = { '<Up>', '<C-p>' },
move_down = { '<Down>', '<C-n>' },
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
cycle_grep_modes = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment',
directory_path = 'Comment', -- Highlight for directory path in file list
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
@@ -187,39 +289,46 @@ local function init()
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
-- Grep highlights
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off (plain mode)
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
},
-- Store file open frecency
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
-- Store successfully opened queries with respective matches
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
enabled = false, -- Show file info panel in preview
show_scores = false, -- Show scores inline in the UI
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
@@ -233,10 +342,10 @@ local function init()
end
--- Setup the file picker with the given configuration
--- @param config table Configuration options
--- @param config FffConfig Configuration options
function M.setup(config) vim.g.fff = config end
--- @return table the fff configuration
--- @return FffConfig the fff configuration
function M.get()
if not state.config then init() end
return state.config
@@ -246,7 +355,7 @@ end
function M.toggle_debug()
local old_debug_state = state.config.debug.show_scores
state.config.debug.show_scores = not state.config.debug.show_scores
state.config.debug.show_file_info = state.config.debug.show_scores
state.config.debug.enabled = state.config.debug.show_scores
local status = state.config.debug.show_scores and 'enabled' or 'disabled'
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
return old_debug_state ~= state.config.debug.show_scores
+1 -6
View File
@@ -46,6 +46,7 @@ local function setup_global_autocmds(config)
vim.api.nvim_create_autocmd('DirChanged', {
group = group,
callback = function()
if vim.v.event.scope == 'window' then return end
local new_cwd = vim.v.event.cwd
if state.initialized and new_cwd and new_cwd ~= config.base_path then
vim.schedule(function()
@@ -64,12 +65,6 @@ local function setup_global_autocmds(config)
end,
desc = 'Automatically sync FFF directory changes',
})
vim.api.nvim_create_autocmd('VimLeavePre', {
group = group,
callback = function() pcall(fuzzy.cleanup_file_picker) end,
desc = 'Cleanup FFF background threads on Neovim exit',
})
end
--- @return boolean
+97 -14
View File
@@ -25,7 +25,26 @@ end
local function binary_exists(plugin_dir)
local binary_path = get_binary_path(plugin_dir)
local stat = vim.uv.fs_stat(binary_path)
return stat and stat.type == 'file'
if stat and stat.type == 'file' then return true end
-- On Windows the rename over a loaded DLL fails, so a verified binary may be
-- left at binary_path .. '.tmp'. Promote it now that the old session is gone.
local tmp_path = binary_path .. '.tmp'
local tmp_stat = vim.uv.fs_stat(tmp_path)
if tmp_stat and tmp_stat.type == 'file' then
-- Verify the .tmp is a valid library before promoting it, in case the
-- process was killed between the loadlib check and the rename attempt
-- during a previous download, leaving a corrupt or partial .tmp on disk.
local loader = package.loadlib(tmp_path, 'luaopen_fff_nvim')
if not loader then
vim.uv.fs_unlink(tmp_path)
return false
end
local ok = vim.uv.fs_rename(tmp_path, binary_path)
return ok ~= nil
end
return false
end
local function download_file(url, output_path, opts, callback)
@@ -53,6 +72,12 @@ local function download_file(url, output_path, opts, callback)
table.insert(curl_args, opts.proxy)
end
if opts.extra_curl_args then
for _, arg in ipairs(opts.extra_curl_args) do
table.insert(curl_args, arg)
end
end
table.insert(curl_args, url)
vim.system(curl_args, {}, function(result)
if result.code ~= 0 then
@@ -71,31 +96,66 @@ local function download_from_github(version, binary_path, opts, callback)
local extension = system.get_lib_extension()
local binary_name = triple .. '.' .. extension
local url = string.format('https://github.com/%s/releases/download/%s/%s', GITHUB_REPO, version, binary_name)
vim.schedule(function()
vim.notify(string.format('Downloading fff.nvim binary for ' .. version), vim.log.levels.INFO)
vim.notify(string.format('Do not open fff until you see a success notification.'), vim.log.levels.WARN)
end)
download_file(url, binary_path, {
-- Download to a temp path first so we can validate before replacing the live binary.
-- If we wrote directly to binary_path and the current process already has the old
-- library loaded, package.loadlib() on the same path returns the *cached* handle —
-- meaning a truncated download would pass validation silently.
-- Using a distinct temp path forces dlopen to load the new file for real.
local tmp_path = binary_path .. '.tmp'
download_file(url, tmp_path, {
proxy = opts.proxy,
extra_curl_args = opts.extra_curl_args,
}, function(success, err)
if not success then
vim.uv.fs_unlink(tmp_path)
callback(false, err)
return
end
-- Verify the binary can be loaded
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
vim.schedule(function()
-- Validate the downloaded binary by actually loading it (temp path is not yet
-- loaded by this process, so dlopen loads the new file for real and catches
-- truncated or corrupt downloads).
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing.
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
if not ok then
vim.uv.fs_unlink(binary_path)
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
return
end
if not loader then
vim.uv.fs_unlink(tmp_path)
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
return
end
vim.schedule(function() vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO) end)
callback(true, nil)
-- Atomically replace the live binary only after successful validation.
-- On Windows the old .dll may be locked by the current process, so rename can
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
-- so the next Neovim start can pick it up automatically.
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
if not rename_ok then
if vim.uv.os_uname().sysname:lower():match('windows') then
vim.notify(
'fff.nvim binary downloaded to '
.. tmp_path
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
vim.log.levels.WARN
)
callback(true, nil)
else
vim.uv.fs_unlink(tmp_path)
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
end
return
end
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
callback(true, nil)
end)
end)
end
@@ -131,7 +191,11 @@ function M.download_binary(callback)
if callback then
callback(false, err)
else
error('Failed to download fff.nvim binary: ' .. (err or 'unknown error'))
vim.schedule(
function()
vim.notify('Failed to download fff.nvim binary: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
end
)
end
return
end
@@ -160,8 +224,14 @@ function M.build_binary(callback)
end
function M.download_or_build_binary()
local done = false
local fatal_error = nil
M.ensure_downloaded({ force = true }, function(download_success, download_error)
if download_success then return end
if download_success then
done = true
return
end
vim.schedule(
function()
@@ -174,12 +244,25 @@ function M.download_or_build_binary()
M.build_binary(function(build_success, build_error)
if not build_success then
error('Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error'))
fatal_error = 'Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error')
else
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
end
done = true
end)
end)
-- Block the caller (and keep the Neovim event loop alive) until the entire
-- download-or-build chain finishes. This is critical for lazy.nvim build
-- hooks: lazy returns from the hook immediately after this function returns,
-- and if Neovim exits before the final rename(tmp → libfff_nvim.{dylib,so,dll})
-- executes, the binary is never written to disk. vim.wait pumps the event
-- loop so all vim.system / vim.schedule callbacks can fire.
local timeout_ms = 1000 * 60 * 2 -- 2 minutes
local ok, wait_err = vim.wait(timeout_ms, function() return done end, 100)
if not ok and wait_err == -2 then error('fff.nvim: download_or_build_binary timed out') end
if fatal_error then error(fatal_error) end
end
function M.get_binary_path()
+1 -1
View File
@@ -44,7 +44,7 @@ function M.get_directory_icon(dirname)
end
elseif M.provider_name == 'mini.icons' then
if M.provider.get then
local icon, hl, is_default = M.provider.get('directory', basename)
local icon, hl, _ = M.provider.get('directory', basename)
if icon and icon ~= '' and hl then return icon, hl end
end
end
+16 -17
View File
@@ -25,11 +25,11 @@ local function reserve_image_buffer_space(bufnr, metadata_lines_count)
table.insert(buffer_lines, '')
end
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
return metadata_lines_count or 2
end
@@ -44,9 +44,9 @@ local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
metadata_lines[i] = info_lines[i] or ''
end
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function identify_image_lines_async(file_path, bufnr, callback)
@@ -144,13 +144,12 @@ end
--- Fully asynchronous
--- @param file_path string Path to the image file
--- @param bufnr number Buffer number to display in
--- @param max_width number Maximum width in characters
--- @param max_height number Maximum height in characters
--- @return boolean
function M.display_image(file_path, bufnr, max_width, max_height)
max_width = max_width or 80
max_height = max_height or 24
vim.api.nvim_buf_set_option(bufnr, 'number', false)
function M.display_image(file_path, bufnr)
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
vim.api.nvim_set_option_value('number', false, { win = win })
end
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
local image_content_starts_at_line = reserved_metadata_lines + 1
@@ -167,9 +166,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
'',
'snacks.nvim plugin is not installed or not available.',
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
@@ -180,9 +179,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
'Terminal does not support image preview.',
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
@@ -193,9 +192,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
'File format is not supported for image preview.',
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
+1 -1
View File
@@ -51,7 +51,7 @@ end
--- @param max_threads number|nil Maximum number of threads to use
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
--- @param page_size number Items per page
--- @param page_size number|nil Items per page (nil uses config default)
--- @return table List of matching files
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
local config = require('fff.conf').get()
+160 -106
View File
@@ -2,51 +2,26 @@ local utils = require('fff.utils')
local file_picker = require('fff.file_picker')
local image = require('fff.file_picker.image')
local location_utils = require('fff.location_utils')
local rust = require('fff.rust')
local M = {}
-- Additional fallback for certain ambiguous filetypes which vim.filetype.match is not handling correctly
local function get_fixed_filetype_detection(extension)
local extension_map = {
ts = 'typescript',
tex = 'latex',
md = 'markdown',
txt = 'text',
}
return extension_map[extension]
end
local function detect_filetype(file_path)
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
if has_plenary then
local detected = plenary_filetype.detect(file_path)
if detected and detected ~= '' then return detected end
end
local builtin_filetype = vim.filetype.match({ filename = file_path })
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
return get_fixed_filetype_detection(extension)
end
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function append_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
if not lines or #lines == 0 then return end
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
local current_lines = vim.api.nvim_buf_line_count(bufnr)
vim.api.nvim_buf_set_lines(bufnr, current_lines, current_lines, false, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function find_existing_buffer(file_path)
@@ -183,7 +158,7 @@ end
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
local ensure_content_loaded_async
local function read_file_streaming_async(file_path, bufnr, callback)
local function read_file_streaming_async(file_path, callback)
local generation = M.state.preview_generation
init_dynamic_loading_async(file_path, function(success, error_msg)
@@ -241,6 +216,13 @@ end
ensure_content_loaded_async = function(target_line)
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if not M.state.has_more_content or M.state.is_loading then return end
-- Guard against missing file handle: without it load_next_chunk_async returns
-- synchronously with empty data, which triggers apply_location_highlighting
-- -> ensure_content_loaded_async again, causing infinite recursion (stack overflow).
if not M.state.file_operation then
M.state.has_more_content = false
return
end
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local buffer_needed = target_line + 50
@@ -278,7 +260,10 @@ ensure_content_loaded_async = function(target_line)
M.apply_location_highlighting(M.state.bufnr)
end
else
-- EOF with no additional data — apply highlighting with whatever we have
-- EOF with no additional data — mark loading as finished to prevent
-- apply_location_highlighting -> ensure_content_loaded_async recursion,
-- then apply highlighting with whatever content we have.
M.state.has_more_content = false
M.apply_location_highlighting(M.state.bufnr)
end
end)
@@ -288,8 +273,8 @@ local function link_buffer_content(source_bufnr, target_bufnr)
local lines = vim.api.nvim_buf_get_lines(source_bufnr, 0, -1, false)
set_buffer_lines(target_bufnr, lines)
local source_ft = vim.api.nvim_buf_get_option(source_bufnr, 'filetype')
if source_ft ~= '' then vim.api.nvim_buf_set_option(target_bufnr, 'filetype', source_ft) end
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
M.state.has_more_content = false
M.state.total_file_lines = #lines
@@ -312,11 +297,13 @@ M.state = {
loading_chunk_size = 1000,
is_loading = false,
has_more_content = true,
file_handle = nil,
file_handle = nil, ---@type uv.uv_fs_t|nil
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
location = nil, -- Current location data for highlighting
location_namespace = nil, -- Namespace for location highlighting
preview_generation = 0, -- Monotonically increasing token to detect stale async callbacks
is_binary_preview = false, -- Whether the current preview is a hex dump
hex_byte_offset = 0, -- Next byte offset for hex dump paging
}
--- Setup preview configuration
@@ -331,9 +318,8 @@ end
--- Check if file is too big for initial preview (inspired by snacks.nvim)
--- @param file_path string Path to the file
--- @param bufnr number|nil Buffer number to check (unused with dynamic loading)
--- @return boolean True if file is too big for initial preview
function M.is_big_file(file_path, bufnr)
function M.is_big_file(file_path)
-- Only check file size for early detection - no line limits with dynamic loading
local stat = vim.uv.fs_stat(file_path)
if stat and stat.size > M.config.max_size then return true end
@@ -358,7 +344,7 @@ function M.get_file_info(file_path)
}
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
info.filetype = detect_filetype(file_path) or 'text'
info.filetype = utils.detect_filetype(file_path) or 'text'
info.size_formatted = utils.format_file_size(info.size)
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
@@ -369,7 +355,7 @@ end
--- Create file info content without custom borders
--- @param file table File information from search results
--- @param info table File system information
--- @param file_index number Index of the file in search results (for score lookup)
--- @param file_index number|nil Index of the file in search results (for score lookup)
--- @return table Lines for the file info content
function M.create_file_info_content(file, info, file_index)
local lines = {}
@@ -474,7 +460,7 @@ end
--- @return boolean Success status
function M.preview_file(file_path, bufnr)
-- Early size detection to prevent memory issues
if M.is_big_file(file_path, bufnr) then
if M.is_big_file(file_path) then
local info = M.get_file_info(file_path)
local lines = {
'File too large for preview',
@@ -501,10 +487,12 @@ function M.preview_file(file_path, bufnr)
if success then
local file_config = M.get_file_config(file_path)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
end
M.state.scroll_offset = 0
@@ -522,7 +510,7 @@ function M.preview_file(file_path, bufnr)
M.state.bufnr = bufnr
local generation = M.state.preview_generation
read_file_streaming_async(file_path, bufnr, function(content, err, loading_more)
read_file_streaming_async(file_path, function(content, err, loading_more)
if M.state.preview_generation ~= generation then
-- Preview moved on to a different file, discard
cleanup_file_operation()
@@ -544,11 +532,13 @@ function M.preview_file(file_path, bufnr)
set_buffer_lines(bufnr, content)
local file_config = M.get_file_config(file_path)
vim.api.nvim_buf_set_option(bufnr, 'filetype', info.filetype)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
end
M.state.content_height = #content
M.state.scroll_offset = 0
@@ -567,7 +557,69 @@ function M.preview_file(file_path, bufnr)
return true
end
--- Preview a binary file with async file type detection
-- Hex preview highlight support: dynamically create hl groups from "#rrggbb"
local hex_ns = nil
local hex_hl_cache = {}
local function ensure_hex_ns()
if not hex_ns then hex_ns = vim.api.nvim_create_namespace('fff_hex_preview') end
return hex_ns
end
local function get_hex_hl_group(hex_color)
local cached = hex_hl_cache[hex_color]
if cached then return cached end
local group = 'FffHex_' .. hex_color:sub(2)
vim.api.nvim_set_hl(0, group, { fg = hex_color })
hex_hl_cache[hex_color] = group
return group
end
--- Apply hex highlight spans to a buffer
--- @param bufnr number Buffer number
--- @param highlights table Array of {line_0idx, col_start, col_end, "#rrggbb"}
--- @param line_offset number Lines to add to each highlight line index (for header)
local function apply_hex_highlights(bufnr, highlights, line_offset)
if not highlights or not vim.api.nvim_buf_is_valid(bufnr) then return end
local ns = ensure_hex_ns()
for _, hl in ipairs(highlights) do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hl[1] + line_offset, hl[2], {
end_col = hl[3],
hl_group = get_hex_hl_group(hl[4]),
})
end
end
--- Load a page of hex dump content from the Rust backend
--- @param file_path string Path to the binary file
--- @param byte_offset number Byte offset to start reading from
--- @return table|nil Result with lines, highlights, has_more, next_offset
local function load_hex_page(file_path, byte_offset)
local ok, result = pcall(rust.hex_dump, file_path, byte_offset, 4096)
if ok and result then return result end
return nil
end
--- Load more hex content when scrolling near the end of the buffer
local function load_more_hex_content()
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if not M.state.has_more_content or not M.state.current_file then return end
local current_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local result = load_hex_page(M.state.current_file, M.state.hex_byte_offset)
if result and result.lines and #result.lines > 0 then
append_buffer_lines(M.state.bufnr, result.lines)
M.state.hex_byte_offset = result.next_offset
M.state.has_more_content = result.has_more
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
M.state.loaded_lines = M.state.content_height
apply_hex_highlights(M.state.bufnr, result.highlights, current_lines)
else
M.state.has_more_content = false
end
end
--- Preview a binary file using hexyl-powered hex dump with paging
--- @param file_path string Path to the file
--- @param bufnr number Buffer number for preview
--- @return boolean Success status
@@ -575,49 +627,40 @@ function M.preview_binary_file(file_path, bufnr)
local info = M.get_file_info(file_path)
local lines = {}
set_buffer_lines(bufnr, lines)
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'text')
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
M.state.is_binary_preview = true
M.state.hex_byte_offset = 0
-- Build header synchronously (file -b is fast, typically <10ms)
if vim.fn.executable('file') == 1 then
local cmd = { 'file', '-b', file_path }
vim.system(cmd, { text = true }, function(result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
local output = vim.fn.system({ 'file', '-b', file_path })
if vim.v.shell_error == 0 and output then
local file_type = output:gsub('\n', '')
table.insert(lines, 'Binary file: ' .. file_type)
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
table.insert(lines, '')
end
end
if result.code == 0 and result.stdout then
local file_type = result.stdout:gsub('\n', '')
table.insert(lines, 'Binary file: ' .. file_type)
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
local hex_result = load_hex_page(file_path, 0)
if hex_result and hex_result.lines then
for _, hex_line in ipairs(hex_result.lines) do
table.insert(lines, hex_line)
end
M.state.hex_byte_offset = hex_result.next_offset
M.state.has_more_content = hex_result.has_more
end
if vim.fn.executable('xxd') == 1 then
table.insert(lines, '')
set_buffer_lines(bufnr, lines)
set_buffer_lines(bufnr, lines)
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
local hex_cmd = { 'xxd', '-l', '8192', file_path }
vim.system(hex_cmd, { text = true }, function(hex_result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
M.state.content_height = #lines
M.state.loaded_lines = #lines
if hex_result.code == 0 and hex_result.stdout then
local hex_lines = vim.split(hex_result.stdout, '\n')
for _, line in ipairs(hex_lines) do
if line:match('%S') then table.insert(lines, line) end
end
else
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
end
set_buffer_lines(bufnr, lines)
end)
end)
else
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
set_buffer_lines(bufnr, lines)
end
end
end)
end)
if hex_result and hex_result.highlights then
local header_lines = #lines - (hex_result.lines and #hex_result.lines or 0)
apply_hex_highlights(bufnr, hex_result.highlights, header_lines)
end
return true
@@ -629,7 +672,7 @@ end
function M.get_file_config(file_path)
if not M.config or not M.config.filetypes then return {} end
local filetype = detect_filetype(file_path) or 'text'
local filetype = utils.detect_filetype(file_path) or 'text'
return M.config.filetypes[filetype] or {}
end
@@ -645,6 +688,7 @@ function M.preview(file_path, bufnr, location, is_binary)
M.state.preview_generation = M.state.preview_generation + 1
if M.state.file_handle then
---@diagnostic disable-next-line: undefined-field
M.state.file_handle:close()
M.state.file_handle = nil
end
@@ -653,6 +697,9 @@ function M.preview(file_path, bufnr, location, is_binary)
M.state.total_file_lines = nil
M.state.has_more_content = true
M.state.is_loading = false
M.state.hex_byte_offset = 0
M.state.is_binary_preview = false
M.state.current_file = file_path
M.state.bufnr = bufnr
@@ -663,10 +710,7 @@ function M.preview(file_path, bufnr, location, is_binary)
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
local win_width = vim.api.nvim_win_get_width(M.state.winid) - 2
local win_height = vim.api.nvim_win_get_height(M.state.winid) - 2
return image.display_image(file_path, bufnr, win_width, win_height)
return image.display_image(file_path, bufnr)
elseif is_binary then
return M.preview_binary_file(file_path, bufnr)
else
@@ -687,15 +731,19 @@ function M.scroll(lines)
-- If scrolling down and approaching end of loaded content, try to load more
if lines > 0 and not M.state.is_loading then
local target_line = new_offset + win_height
local buffer_needed = target_line + 20 -- Load a bit ahead
local buffer_needed = target_line + 20
if current_buffer_lines < buffer_needed and M.state.has_more_content then
-- Load more content asynchronously but don't wait for it
ensure_content_loaded_async(target_line)
if M.state.is_binary_preview then
load_more_hex_content()
-- Re-read line count after loading more
current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
else
ensure_content_loaded_async(target_line)
end
end
end
-- Use actual buffer line count for scroll calculations
local content_height = current_buffer_lines
local half_screen = math.floor(win_height / 2)
local max_scroll = math.max(0, content_height + half_screen - win_height)
@@ -744,10 +792,15 @@ function M.update_file_info_buffer(file, bufnr, file_index)
end
set_buffer_lines(bufnr, file_info_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(bufnr, 'wrap', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
-- Set wrap on the window (wrap is window-local, not buffer-local)
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
if vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_option_value('wrap', false, { win = win }) end
end
return true
end
@@ -787,10 +840,10 @@ function M.clear_buffer(bufnr)
pcall(vim.treesitter.stop, bufnr)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_option(bufnr, 'filetype', '')
vim.api.nvim_buf_set_option(bufnr, 'syntax', '')
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
set_buffer_lines(bufnr, {})
end
@@ -812,6 +865,8 @@ function M.clear()
M.state.scroll_offset = 0
M.state.content_height = 0
M.state.location = nil
M.state.is_binary_preview = false
M.state.hex_byte_offset = 0
end
--- Apply location highlighting to the preview buffer
@@ -829,7 +884,6 @@ function M.apply_location_highlighting(bufnr)
if not M.state.location then return end
-- Apply highlighting
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
+47 -55
View File
@@ -2,30 +2,6 @@
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
local M = {}
--- Render Context passed to renderer functions
--- @class RenderContext
--- @field config table User configuration from conf.get()
--- @field items table[] Array of file items being rendered
--- @field cursor number Current cursor position (1-based index into items)
--- @field win_height number Window height in lines
--- @field win_width number Window width in columns
--- @field max_path_width number Maximum width for file paths
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
--- @field prompt_position string Prompt position: 'top' or 'bottom'
--- @field has_combo boolean Whether combo boost is active
--- @field combo_header_line string Formatted combo header line (if has_combo)
--- @field combo_header_text_len number Length of combo header text (if has_combo)
--- @field combo_item_index number Index of item with combo (usually 1)
--- @field display_start number Start index for displayed items
--- @field display_end number End index for displayed items
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
--- @field selected_files table<string, boolean> Map of selected file paths
--- @field query string Current search query
--- @field renderer table|nil Custom renderer (if provided via opts)
--- File Item structure from Rust
--- @class FileItem
--- @field path string Absolute file path
@@ -37,28 +13,23 @@ local M = {}
--- @field total_frecency_score number Total frecency score
--- @field access_frecency_score number Access-based frecency score
--- @field modification_frecency_score number Modification-based frecency score
--- @field git_status number|nil Git status enum (if file is in git repo)
--- Renderer Interface:
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
--- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
--- internal:
--- @field _has_group_header boolean Internal flag for render_line to indicate if this item has a combo header line (not from Rust)
--- Render a file item line
--- @param item FileItem File item from Rust
--- @param ctx RenderContext Render context with all state
--- @param ctx ListRenderContext Render context with all state
--- @param item_idx number Item index (1-based)
--- @return string[] Array of line strings (1 or 2 lines if combo)
function M.render_line(item, ctx, item_idx)
local icons = require('fff.file_picker.icons')
local lines = {}
-- Check if this should have combo header (first item with combo boost)
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
if has_combo then table.insert(lines, ctx.combo_header_line) end
-- Get icon
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
local icon, _ = icons.get_icon(item.name, item.extension, false)
-- Build frecency indicator (debug mode only)
local frecency = ''
@@ -72,7 +43,7 @@ function M.render_line(item, ctx, item_idx)
if mod >= 6 then
indicator = '🔥'
elseif access >= 4 then
indicator = ''
indicator = ''
elseif total >= 3 then
indicator = ''
elseif total >= 1 then
@@ -100,7 +71,7 @@ end
--- Apply highlights to a rendered line
--- @param item FileItem File item from Rust
--- @param ctx RenderContext Render context with all state
--- @param ctx ListRenderContext Render context with all state
--- @param item_idx number Item index (1-based)
--- @param buf number Buffer handle
--- @param ns_id number Namespace ID
@@ -126,7 +97,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
end_col = 0,
end_row = line_idx,
hl_group = ctx.config.hl.active_file,
hl_group = ctx.config.hl.cursor,
hl_eol = true,
priority = 100,
})
@@ -135,7 +106,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
-- 2. Icon
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
local icon_hl = is_current_file and 'Comment' or icon_hl_group
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
0,
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
)
end
-- 3. Git text color (filename)
@@ -143,15 +120,27 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
if git_text_hl and git_text_hl ~= '' and not is_current_file then
local filename_start = #icon + 1
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
filename_start,
{ end_col = filename_start + #filename, hl_group = git_text_hl }
)
end
end
-- 4. Frecency indicator
if ctx.debug_enabled then
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
if start_pos then
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
if start_pos and end_pos then
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
start_pos - 1,
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
)
end
end
@@ -161,22 +150,26 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
if icon then
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
end
vim.api.nvim_buf_add_highlight(
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
ctx.config.hl.directory_path or 'Comment',
line_idx - 1,
prefix_len,
prefix_len + #dir_path
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
)
end
-- 6. Current file
if is_current_file then
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
local hl
if is_cursor then
hl = ctx.config.hl.cursor
else
hl = 'Comment'
end
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
virt_text = { { ' (current)', virt_text_hl } },
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
virt_text_pos = 'right_align',
})
end
@@ -187,10 +180,10 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
local border_hl
if is_cursor then
local base_hl = git_utils.get_border_highlight(item.git_status)
local base_hl = git_utils.get_border_highlight_selected(item.git_status)
if base_hl and base_hl ~= '' then
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), 'bg')
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
if border_fg ~= '' and cursor_bg ~= '' then
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
@@ -199,7 +192,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
border_hl = git_utils.get_border_highlight_selected(item.git_status)
end
else
border_hl = ctx.config.hl.active_file
border_hl = ctx.config.hl.cursor
end
else
border_hl = git_utils.get_border_highlight(item.git_status)
@@ -215,7 +208,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
elseif is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = ' ',
sign_hl_group = ctx.config.hl.active_file,
sign_hl_group = ctx.config.hl.cursor,
priority = 1000,
})
end
@@ -234,13 +227,12 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
if ctx.query and ctx.query ~= '' then
local match_start, match_end = string.find(line_content, ctx.query, 1)
if match_start and match_end then
vim.api.nvim_buf_add_highlight(
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
ctx.config.hl.matched or 'IncSearch',
line_idx - 1,
match_start - 1,
match_end
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
)
end
end
+5 -5
View File
@@ -109,20 +109,20 @@ function M.setup_highlights()
vim.cmd([[
" Symbol highlights
highlight default FFFGitStaged guifg=#10B981 ctermfg=2
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
highlight default FFFGitDeleted guifg=#EF4444 ctermfg=1
highlight default FFFGitRenamed guifg=#8B5CF6 ctermfg=5
highlight default FFFGitUntracked guifg=#10B981 ctermfg=2
highlight default FFFGitIgnored guifg=#4B5563 ctermfg=8
" Thin border highlights
" Thin border highlights
highlight default FFFGitSignStaged guifg=#10B981 ctermfg=2
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
highlight default FFFGitSignDeleted guifg=#EF4444 ctermfg=1
highlight default FFFGitSignRenamed guifg=#8B5CF6 ctermfg=5
highlight default FFFGitSignUntracked guifg=#10B981 ctermfg=2
highlight default FFFGitSignIgnored guifg=#4B5563 ctermfg=8
" Fallback to GitSigns highlights if they exist
highlight default link FFFGitSignStaged GitSignsAdd
highlight default link FFFGitSignModified GitSignsChange
+24 -35
View File
@@ -6,27 +6,26 @@
local M = {}
local file_renderer = require('fff.file_renderer')
local tresitter_highlight = require('fff.treesitter_hl')
--- Build the file group header line using the same layout as file_renderer.
--- Delegates to file_renderer.render_line (with combo disabled).
---@param item table Grep match item (used for file metadata)
---@param item FileItem Grep match
---@param ctx table Render context
---@return string The header line string
local function build_group_header(item, ctx)
-- file_renderer.render_line checks (item_idx == 1 and ctx.has_combo) for combo header.
-- We pass item_idx=0 and disable has_combo to suppress combo logic entirely.
local saved_has_combo = ctx.has_combo
ctx.has_combo = false
---@diagnostic disable-next-line: param-type-mismatch
local lines = file_renderer.render_line(item, ctx, 0)
ctx.has_combo = saved_has_combo
ctx.has_combo = false -- never has a combo in grep
return lines[1]
end
--- Apply highlights for a file group header line using file_renderer.
--- Delegates to file_renderer.apply_highlights so all highlight groups
--- (icon, filename, git text color, directory path, git sign) match exactly.
---@param item table Grep match item
---@param ctx table Render context
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer (header line)
@@ -48,17 +47,17 @@ end
local function render_match_line(item, ctx)
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
local separator = ' '
local raw_content = item.line_content or ''
local leading_ws = #raw_content - #raw_content:match('^%s*(.*)')
local content = vim.trim(raw_content)
-- vim.json.decode may return Blobs for strings with NUL bytes; coerce to string.
local raw_content = item.line_content
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
local content = raw_content
-- Indent + location + separator + content
local indent = ' '
-- Prefix is always ASCII so byte length == display width
local prefix_display_w = #indent + #location + #separator
local available = ctx.win_width - prefix_display_w - 2
local was_truncated = false
local content_display_w = vim.fn.strdisplaywidth(content)
if content_display_w > available and available > 3 then
-- UTF-8 aware truncation: binary search for the character count that
-- fits within the available display width (handles multi-byte and wide chars)
@@ -73,15 +72,11 @@ local function render_match_line(item, ctx)
end
end
content = vim.fn.strcharpart(content, 0, lo) .. ''
was_truncated = true
end
local line = indent .. location .. separator .. content
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
-- Store transient data on item for highlight pass
item._leading_ws = leading_ws
item._was_truncated = was_truncated
item._match_indent = #indent
item._content_offset = prefix_display_w -- byte offset where content starts in the line
item._trimmed_content = content -- trimmed content string for treesitter parsing
@@ -102,9 +97,6 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
local is_cursor = item_idx == ctx.cursor
local indent = item._match_indent or 1
-- 1. Cursor line highlight — use hl_group + hl_eol instead of line_hl_group
-- so that higher-priority inline extmarks (IncSearch match ranges at 200)
-- cleanly override both fg and bg on the cursor line.
if is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
end_col = 0,
@@ -143,16 +135,16 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- below IncSearch match ranges (200) so search matches take precedence.
local content_start = sep_end
if item._trimmed_content and item.name then
local ts_hl = require('fff.treesitter_hl')
-- Resolve language once per file group (cache on the render context)
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
local lang = ctx._ts_lang_cache[item.name]
if lang == nil then
lang = ts_hl.lang_from_filename(item.name) or false
lang = tresitter_highlight.lang_from_filename(item.name) or false
ctx._ts_lang_cache[item.name] = lang
end
if lang then
local highlights = ts_hl.get_line_highlights(item._trimmed_content, lang)
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
for _, hl in ipairs(highlights) do
local hl_start = content_start + hl.col
local hl_end = content_start + hl.end_col
@@ -171,16 +163,14 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- Use extmarks with priority > cursor line (100) so IncSearch renders
-- properly on the selected line instead of being overridden by CursorLine.
if item.match_ranges then
local leading_ws = item._leading_ws or 0
for _, range in ipairs(item.match_ranges) do
local raw_start = range[1] or 0
local raw_end = range[2] or 0
local adj_start = raw_start - leading_ws
local adj_end = raw_end - leading_ws
if adj_end > 0 then
adj_start = math.max(0, adj_start)
local hl_start = content_start + adj_start
local hl_end = content_start + adj_end
if raw_end > 0 then
raw_start = math.max(0, raw_start)
local hl_start = content_start + raw_start
local hl_end = content_start + raw_end
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
@@ -208,11 +198,10 @@ end
--- Render a single item's lines (called by list_renderer's generate_item_lines).
--- Returns 2 lines [header, match] for the first match of a file group,
--- or 1 line [match] for subsequent matches in the same file.
---@param item table Grep match item
---@param item FileItem Grep match item
---@param ctx table Render context
---@param item_idx number 1-based item index
---@return string[]
function M.render_line(item, ctx, item_idx)
function M.render_line(item, ctx)
-- Track file grouping across the render pass via ctx
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
local is_new_group = (item.path ~= ctx._grep_last_file)
@@ -221,8 +210,8 @@ function M.render_line(item, ctx, item_idx)
local match_line = render_match_line(item, ctx)
if is_new_group then
local header_line = build_group_header(item, ctx)
item._has_group_header = true
local header_line = build_group_header(item, ctx)
return { header_line, match_line }
else
item._has_group_header = false
@@ -233,8 +222,8 @@ end
--- Apply highlights for rendered lines (called by list_renderer's apply_all_highlights).
--- line_idx is the 1-based index of the item's LAST line (the match line).
--- If the item has a group header, it's at line_idx - 1.
---@param item table Grep match item
---@param ctx table Render context
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
+1
View File
@@ -12,6 +12,7 @@ local fuzzy = require('fff.fuzzy')
---@field total_files number Total indexed files
---@field filtered_file_count number Total searchable files after filtering
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
local last_result = nil
+39 -24
View File
@@ -15,7 +15,7 @@
local M = {}
--- @class ListRenderContext
--- @field config table User configuration
--- @field config FffConfig User configuration
--- @field items table[] Array of data items to render
--- @field cursor number Current cursor position (1-based index into items)
--- @field win_height number Window height in lines
@@ -37,6 +37,7 @@ local M = {}
--- @field selected_files table<string, boolean> Selected file paths set
--- @field mode string|nil Current mode (nil or 'grep')
--- @field format_file_display function Helper for formatting file display
--- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files')
--- @class ItemLineMapping
--- @field first number First buffer line (1-based) this item occupies
@@ -54,7 +55,7 @@ local M = {}
--- When cross-mode suggestions are active, a suggestion banner is prepended
--- (for top prompt) or appended (for bottom prompt) so it always appears
--- above the suggestion items visually.
--- @param ctx ListRenderContext
--- @param ctx table
--- @return string[] lines Array of line strings
--- @return table<number, ItemLineMapping> item_to_lines
local function generate_item_lines(ctx)
@@ -68,8 +69,16 @@ local function generate_item_lines(ctx)
local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0
if has_suggestion_header then
table.insert(suggestion_header_lines, '')
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
if ctx.mode == 'grep' and ctx.suggestion_source == 'files' then
-- Grep mode with no results — hint about mode cycling to fuzzy search
local config = require('fff.conf').get()
local keybind = config.keymaps.cycle_grep_modes
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
table.insert(suggestion_header_lines, ' No results, try ' .. keybind .. ' to fuzzy search')
else
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
end
table.insert(suggestion_header_lines, '')
end
@@ -90,11 +99,9 @@ local function generate_item_lines(ctx)
-- Renderer returns 1+ lines: virtual headers first, content line last.
-- This contract is shared by file_renderer (combo header) and
-- grep_renderer (file group header).
---@diagnostic disable-next-line: param-type-mismatch
local item_lines = renderer.render_line(item, ctx, i)
for _, line in ipairs(item_lines) do
table.insert(lines, line)
end
vim.list_extend(lines, item_lines)
local item_end_line = #lines
local virtual_count = item_end_line - item_start_line -- 0 if single line, 1 if header + content
@@ -120,7 +127,7 @@ end
--- Adjusts all line indices in item_to_lines accordingly.
--- @param lines string[] Lines array (mutated)
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
--- @param ctx ListRenderContext
--- @param ctx table
--- @return number padding_offset Number of empty lines prepended
local function apply_bottom_padding(lines, item_to_lines, ctx)
if ctx.prompt_position ~= 'bottom' then return 0 end
@@ -151,7 +158,7 @@ end
--- never a virtual header line.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx ListRenderContext
--- @param ctx table
--- @param list_buf number Buffer handle
--- @param list_win number Window handle
--- @param ns_id number Namespace id
@@ -163,9 +170,9 @@ local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, lis
if cursor_item then cursor_line = cursor_item.last end
end
vim.api.nvim_buf_set_option(list_buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = list_buf })
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
vim.api.nvim_buf_set_option(list_buf, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf })
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
@@ -180,7 +187,7 @@ end
--- header highlights internally via the item._has_group_header flag.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx ListRenderContext
--- @param ctx table
--- @param list_buf number
--- @param ns_id number
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
@@ -190,23 +197,24 @@ local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
for i = ctx.display_start, ctx.display_end do
local item = ctx.items[i]
local item_lines = item_to_lines[i]
if not item_lines then goto continue end
-- The content line is always the last line in the mapping
local line_idx = item_lines.last
local line_content = lines[line_idx]
if item_lines then
-- The content line is always the last line in the mapping
local line_idx = item_lines.last
local line_content = lines[line_idx]
if not line_content then goto continue end
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
::continue::
if line_content then
---@diagnostic disable-next-line: param-type-mismatch
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
end
end
end
end
--- Render the full item list into the buffer.
--- This is the main entry point — replaces the inline rendering in picker_ui.
---
--- @param ctx ListRenderContext Render context built by picker_ui
--- @param ctx table Render context built by picker_ui
--- @param list_buf number List buffer handle
--- @param list_win number List window handle
--- @param ns_id number Highlight namespace
@@ -224,8 +232,15 @@ function M.render(ctx, list_buf, list_win, ns_id)
local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg'
for i = 0, #lines - 1 do
local line = lines[i + 1]
if line and line:match('^%s+No results found') then
pcall(vim.api.nvim_buf_add_highlight, list_buf, ns_id, suggestion_hl, i, 0, -1)
if line and (line:match('^%s+No results found') or line:match('^%s+No results,')) then
pcall(
vim.api.nvim_buf_set_extmark,
list_buf,
ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
)
end
end
end
+15 -37
View File
@@ -18,13 +18,7 @@ function M.find_files(opts)
end
--- Live grep: search file contents in the current directory
--- @param opts? table Optional configuration overrides
--- @param opts.cwd? string Custom working directory
--- @param opts.title? string Window title (default: "Live Grep")
--- @param opts.prompt? string Input prompt text (default: "grep> ")
--- @param opts.layout? table Layout overrides
--- @param opts.grep? table Grep-specific overrides {max_file_size, smart_case, max_matches_per_file, modes}
--- @param opts.grep.modes? table Available search modes and their cycling order (default: {'plain', 'regex', 'fuzzy'})
--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string} Optional configuration overrides
function M.live_grep(opts)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if not picker_ok then
@@ -37,16 +31,19 @@ function M.live_grep(opts)
local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {})
local picker_opts = vim.tbl_deep_extend('force', opts or {}, {
title = (opts and opts.title) or 'Live Grep',
local picker_opts = vim.tbl_deep_extend('force', {
title = 'Live Grep',
mode = 'grep',
renderer = grep_renderer,
grep_config = grep_config,
})
query = '',
}, opts or {})
picker_ui.open(picker_opts)
end
--- Changes the directory indexed by the file picker to the git root and opens the file picker
--- @deprecated Use `find_files` instead
function M.find_in_git_root()
local fuzzy = require('fff.core').ensure_initialized()
local ok, git_root = pcall(fuzzy.get_git_root)
@@ -168,11 +165,12 @@ function M.find_files_in_dir(directory)
return
end
M.change_indexing_directory(directory)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
picker_ui.open({
title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t'),
cwd = directory,
})
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
@@ -182,35 +180,15 @@ end
--- @param new_path string New directory path to use as base
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path)
if not new_path or new_path == '' then
vim.notify('Directory path is required', vim.log.levels.ERROR)
return false
end
local expanded_path = vim.fn.expand(new_path)
if vim.fn.isdirectory(expanded_path) ~= 1 then
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
return false
end
local fuzzy = require('fff.core').ensure_initialized()
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
if not ok then
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
return false
end
local config = require('fff.conf').get()
config.base_path = expanded_path
return true
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then return picker_ui.change_indexing_directory(new_path) end
return false
end
--- Opens the file under the cursor with an optional callback if the only file
--- is found and we are about to inline open it
--- @param open_cb function|nil Optional callback function to execute after opening the file
function M.open_file_under_cursor(open_cb)
local filename = vim.fn.expand('<cfile>')
local full_path_with_suffix = vim.fn.expand('<cWORD>')
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
@@ -219,7 +197,7 @@ function M.open_file_under_cursor(open_cb)
return
end
picker_ui.open_with_callback(full_path_with_suffix, function(files, metadata, location, get_file_score)
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
if open_cb and type(open_cb) == 'function' then open_cb(files[1].path) end
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].path)))
+477 -321
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -38,7 +38,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
-- rendering in a separate buffer to overflow the border
if not scrollbar_exists then
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scrollbar_state.buf })
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
relative = 'editor',
@@ -52,7 +52,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
})
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
vim.api.nvim_set_option_value('winhighlight', scrollbar_hl, { win = scrollbar_state.win })
scrollbar_state.ever_shown = true
end
@@ -83,9 +83,9 @@ function M.render(layout, config, list_win, pagination, prompt_position)
end
end
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
pcall(vim.api.nvim_set_option_value, 'modifiable', true, { buf = scrollbar_state.buf })
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
pcall(vim.api.nvim_set_option_value, 'modifiable', false, { buf = scrollbar_state.buf })
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
if thumb_size > 0 then
+4 -10
View File
@@ -1,7 +1,5 @@
--- Treesitter Highlight Extraction
--- Extracts syntax highlights from a code string using treesitter.
--- Uses a per-language scratch buffer pool to avoid repeated buffer creation.
--- Results are returned as extmark-style tables { col, end_col, hl_group }.
local utils = require('fff.utils')
local M = {}
--- Per-language scratch buffer cache
@@ -33,11 +31,7 @@ end
function M.lang_from_filename(filename)
if not filename or filename == '' then return nil end
-- Use vim.filetype.match to get the filetype from the filename
local ok, ft = pcall(vim.filetype.match, { filename = filename })
if not ok or not ft then return nil end
-- Convert filetype to treesitter language
local ft = utils.detect_filetype(filename) or 'text'
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
if not lang_ok or not lang then lang = ft end
@@ -83,7 +77,7 @@ function M.get_line_highlights(text, lang)
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
if not query_ok or not query then return end
for capture, node, metadata in query:iter_captures(root, buf, 0, 1) do
for capture, node, _ in query:iter_captures(root, buf, 0, 1) do
local name = query.captures[capture]
if name and name ~= 'spell' and name ~= 'conceal' then
local start_row, start_col, end_row, end_col = node:range()
+30 -1
View File
@@ -17,6 +17,34 @@ function M.format_file_size(size)
end
end
local function get_fixed_filetype_detection(extension)
local extension_map = {
ts = 'typescript',
tex = 'latex',
md = 'markdown',
txt = 'text',
}
return extension_map[extension]
end
--- Detect filetype with various fallbacks
--- @param file_path string the filetype
--- @return string detected filetype
function M.detect_filetype(file_path)
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
if has_plenary then
local detected = plenary_filetype.detect(file_path, {})
if detected and detected ~= '' then return detected end
end
local builtin_filetype = vim.filetype.match({ filename = file_path })
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
return get_fixed_filetype_detection(extension)
end
--- Safely resolve a config value that can be either a static value or a function
--- @param config_value any The config value (can be function or static value)
--- @param terminal_width number Terminal width for function calls
@@ -24,7 +52,7 @@ end
--- @param validator function Function to validate the result
--- @param fallback any Fallback value if function fails or returns invalid value
--- @param error_context string Context for error messages
--- @return any The resolved and validated value
--- @return number The resolved and validated value
function M.resolve_config_value(config_value, terminal_width, terminal_height, validator, fallback, error_context)
if type(config_value) == 'function' then
local success, result = pcall(config_value, terminal_width, terminal_height)
@@ -38,6 +66,7 @@ function M.resolve_config_value(config_value, terminal_width, terminal_height, v
return fallback
end
else
if config_value == nil or not validator(config_value) then return fallback end
return config_value
end
end
+15 -9
View File
@@ -11,18 +11,24 @@ function M.get_triple()
if os_name == 'darwin' then
os_name = 'apple-darwin'
elseif os_name == 'linux' then
-- Detect if we're on musl or glibc
local handle = io.popen('ldd --version 2>&1')
if handle then
local output = handle:read('*a')
handle:close()
if output and output:match('musl') then
os_name = 'unknown-linux-musl'
-- Detect Android/Termux before checking musl/glibc.
-- Termux uses Bionic libc (not glibc or musl) and has no ldd.
if os.getenv('TERMUX_VERSION') or os.getenv('ANDROID_ROOT') then
os_name = 'linux-android'
else
-- Detect if we're on musl or glibc
local handle = io.popen('ldd --version 2>&1')
if handle then
local output = handle:read('*a')
handle:close()
if output and output:match('musl') then
os_name = 'unknown-linux-musl'
else
os_name = 'unknown-linux-gnu'
end
else
os_name = 'unknown-linux-gnu'
end
else
os_name = 'unknown-linux-gnu'
end
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
os_name = 'pc-windows-msvc'
+15
View File
@@ -0,0 +1,15 @@
{
"private": true,
"workspaces": ["packages/fff-bun", "packages/fff-mcp"],
"packageManager": "bun@1.3.9",
"scripts": {
"format": "biome format --write",
"format:check": "biome format",
"lint": "biome lint",
"check": "biome check --write",
"check:ci": "biome check"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4"
}
}
-10
View File
@@ -1,10 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
# Native binaries (downloaded at install)
bin/*.dylib
bin/*.so
bin/*.dll
+1 -1
View File
@@ -120,7 +120,7 @@ Search file contents with SIMD-accelerated matching.
```typescript
interface GrepOptions {
maxFileSize?: number; // Max file size in bytes (default: 10MB)
maxMatchesPerFile?: number; // Max matches per file (default: 200)
maxMatchesPerFile?: number; // Max matches per file (default: 200, set 0 to unlimited)
smartCase?: boolean; // Case-insensitive if all lowercase (default: true)
fileOffset?: number; // Pagination offset (default: 0)
pageLimit?: number; // Max matches to return (default: 50)
Binary file not shown.
+10
View File
@@ -8,6 +8,16 @@
"@types/bun": "^1.3.8",
"typescript": "^5.0.0",
},
"optionalDependencies": {
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
"@ff-labs/fff-bun-win32-x64": "0.0.0",
},
"peerDependencies": {
"bun": ">=1.0.0",
},
+26 -39
View File
@@ -11,7 +11,7 @@
import { FileFinder } from "../src/index";
import type { GrepMode } from "../src/types";
import * as readline from "readline";
import * as readline from "node:readline";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
@@ -36,7 +36,7 @@ function formatGitStatus(status: string): string {
return `${RED}D${RESET}`;
case "renamed":
return `${BLUE}R${RESET}`;
case "clear":
case "clean":
case "current":
return `${DIM} ${RESET}`;
default:
@@ -64,9 +64,7 @@ function highlightLine(content: string, ranges: [number, number][]): string {
if (s > lastEnd) {
parts.push(buf.subarray(lastEnd, s).toString("utf-8"));
}
parts.push(
`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`
);
parts.push(`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`);
lastEnd = e;
}
@@ -111,45 +109,45 @@ async function main() {
}
console.log(`${DIM}Initializing index for: ${directory}${RESET}`);
const initResult = FileFinder.init({
const createResult = FileFinder.create({
basePath: directory,
warmupMmapCache: true,
});
if (!initResult.ok) {
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
if (!createResult.ok) {
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
process.exit(1);
}
const finder = createResult.value;
// Wait for scan
process.stdout.write(`${DIM}Scanning files...${RESET}`);
const startTime = Date.now();
while (FileFinder.isScanning()) {
const progress = FileFinder.getScanProgress();
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok) {
process.stdout.write(
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `,
);
}
await new Promise((r) => setTimeout(r, 50));
}
const scanTime = Date.now() - startTime;
const finalProgress = FileFinder.getScanProgress();
const totalFiles = finalProgress.ok
? finalProgress.value.scannedFilesCount
: 0;
const finalProgress = finder.getScanProgress();
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
console.log(
`\r${GREEN}${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`
`\r${GREEN}${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`,
);
console.log(
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`,
);
console.log(
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`,
);
let currentMode = mode;
@@ -166,7 +164,7 @@ async function main() {
rl.question(`${CYAN}grep[${modeLabel}]>${RESET} `, (query) => {
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
console.log(`\n${DIM}Goodbye!${RESET}`);
FileFinder.destroy();
finder.destroy();
rl.close();
process.exit(0);
}
@@ -174,16 +172,12 @@ async function main() {
// Handle mode switching
if (query.startsWith(":mode ")) {
const newMode = query.slice(6).trim();
if (
newMode === "plain" ||
newMode === "regex" ||
newMode === "fuzzy"
) {
if (newMode === "plain" || newMode === "regex" || newMode === "fuzzy") {
currentMode = newMode;
console.log(`${DIM}Switched to ${currentMode} mode${RESET}\n`);
} else {
console.log(
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`,
);
}
prompt();
@@ -196,7 +190,7 @@ async function main() {
}
const searchStart = Date.now();
const result = FileFinder.liveGrep(query, {
const result = finder.liveGrep(query, {
mode: currentMode,
pageLimit: 30,
timeBudgetMs: 5000,
@@ -213,7 +207,7 @@ async function main() {
items,
totalMatched,
totalFilesSearched,
totalFiles: indexedFiles,
totalFiles: _,
filteredFileCount,
nextCursor,
regexFallbackError,
@@ -223,12 +217,12 @@ async function main() {
if (regexFallbackError) {
console.log(
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`,
);
}
console.log(
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`,
);
console.log();
@@ -244,16 +238,11 @@ async function main() {
if (match.relativePath !== lastFile) {
lastFile = match.relativePath;
const git = formatGitStatus(match.gitStatus);
console.log(
`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`
);
console.log(`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`);
}
const lineNum = String(match.lineNumber).padStart(4);
const highlighted = highlightLine(
match.lineContent,
match.matchRanges
);
const highlighted = highlightLine(match.lineContent, match.matchRanges);
let suffix = "";
if (match.fuzzyScore !== undefined) {
@@ -264,9 +253,7 @@ async function main() {
}
if (nextCursor) {
console.log(
`\n${DIM}... more results available${RESET}`
);
console.log(`\n${DIM}... more results available${RESET}`);
}
console.log();
+36 -29
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env bun
/**
* Interactive file finder demo
*
* Usage:
*
* Usage:
* bunx fff-demo [directory]
* bun examples/search.ts [directory]
*
*
* Indexes the specified directory (or cwd) and provides an interactive
* search prompt with detailed metadata about results.
*/
import { FileFinder } from "../src/index";
import * as readline from "readline";
import * as readline from "node:readline";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
@@ -19,7 +19,6 @@ const DIM = "\x1b[2m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const BLUE = "\x1b[34m";
const MAGENTA = "\x1b[35m";
const CYAN = "\x1b[36m";
const RED = "\x1b[31m";
@@ -35,7 +34,7 @@ function formatGitStatus(status: string): string {
return `${RED}D${RESET}`;
case "renamed":
return `${BLUE}R${RESET}`;
case "clear":
case "clean":
case "current":
return `${DIM} ${RESET}`;
default:
@@ -74,7 +73,7 @@ function formatTime(unixSeconds: number): string {
async function main() {
const targetDir = process.argv[2] || process.cwd();
console.log(`${BOLD}${CYAN}fff - Fast File Finder Demo${RESET}\n`);
// Check library availability
@@ -84,24 +83,26 @@ async function main() {
process.exit(1);
}
// Initialize
// Create instance
console.log(`${DIM}Initializing index for: ${targetDir}${RESET}`);
const initResult = FileFinder.init({
const createResult = FileFinder.create({
basePath: targetDir,
});
if (!initResult.ok) {
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
if (!createResult.ok) {
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
process.exit(1);
}
const finder = createResult.value;
// Wait for scan with progress
process.stdout.write(`${DIM}Scanning files...${RESET}`);
const startTime = Date.now();
let lastCount = 0;
while (FileFinder.isScanning()) {
const progress = FileFinder.getScanProgress();
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
lastCount = progress.value.scannedFilesCount;
process.stdout.write(`\r${DIM}Scanning files... ${lastCount}${RESET} `);
@@ -110,13 +111,15 @@ async function main() {
}
const scanTime = Date.now() - startTime;
const finalProgress = FileFinder.getScanProgress();
const finalProgress = finder.getScanProgress();
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
console.log(`\r${GREEN}${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`);
console.log(
`\r${GREEN}${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`,
);
// Show index info
const health = FileFinder.healthCheck();
const health = finder.healthCheck();
if (health.ok) {
console.log(`${DIM}Version:${RESET} ${health.value.version}`);
console.log(`${DIM}Base path:${RESET} ${health.value.filePicker.basePath}`);
@@ -131,19 +134,21 @@ async function main() {
output: process.stdout,
});
console.log(`${BOLD}Enter a search query${RESET} (or 'q' to quit, empty for all files):\n`);
console.log(
`${BOLD}Enter a search query${RESET} (or 'q' to quit, empty for all files):\n`,
);
const prompt = () => {
rl.question(`${CYAN}search>${RESET} `, (query) => {
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
console.log(`\n${DIM}Goodbye!${RESET}`);
FileFinder.destroy();
finder.destroy();
rl.close();
process.exit(0);
}
const searchStart = Date.now();
const result = FileFinder.search(query, { pageSize: 15 });
const result = finder.search(query, { pageSize: 15 });
const searchTime = Date.now() - searchStart;
if (!result.ok) {
@@ -156,7 +161,7 @@ async function main() {
console.log();
console.log(
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`,
);
console.log();
@@ -167,9 +172,7 @@ async function main() {
}
// Header
console.log(
`${DIM} Git │ Score │ Size │ Modified │ Path${RESET}`
);
console.log(`${DIM} Git │ Score │ Size │ Modified │ Path${RESET}`);
// Results
for (let i = 0; i < items.length; i++) {
@@ -183,7 +186,7 @@ async function main() {
const path = item.relativePath;
console.log(
` ${gitStatus}${totalScore.padStart(5)}${size}${modified}${path}`
` ${gitStatus}${totalScore.padStart(5)}${size}${modified}${path}`,
);
// Show score breakdown for top results
@@ -192,19 +195,23 @@ async function main() {
if (score.baseScore > 0) breakdown.push(`base:${score.baseScore}`);
if (score.filenameBonus > 0) breakdown.push(`filename:+${score.filenameBonus}`);
if (score.frecencyBoost > 0) breakdown.push(`frecency:+${score.frecencyBoost}`);
if (score.comboMatchBoost > 0) breakdown.push(`combo:+${score.comboMatchBoost}`);
if (score.distancePenalty < 0) breakdown.push(`distance:${score.distancePenalty}`);
if (score.comboMatchBoost > 0)
breakdown.push(`combo:+${score.comboMatchBoost}`);
if (score.distancePenalty < 0)
breakdown.push(`distance:${score.distancePenalty}`);
if (score.exactMatch) breakdown.push(`${GREEN}exact${RESET}`);
if (breakdown.length > 0) {
console.log(`${DIM} │ │ │ │ └─ ${breakdown.join(", ")}${RESET}`);
console.log(
`${DIM} │ │ │ │ └─ ${breakdown.join(", ")}${RESET}`,
);
}
}
}
if (totalMatched > items.length) {
console.log(
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
);
}
+15 -13
View File
@@ -7,12 +7,13 @@
* bunx fff info - Show platform and binary info
*/
import {
downloadBinary,
getBinaryPath,
findBinary,
} from "../src/download";
import { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "../src/platform";
import { downloadBinary, getBinaryPath, findBinary } from "../src/download";
import {
getTriple,
getLibExtension,
getLibFilename,
getNpmPackageName,
} from "../src/platform";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -26,7 +27,7 @@ interface PackageJson {
async function getPackageInfo(): Promise<PackageJson> {
const currentDir = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(currentDir, "..", "package.json");
try {
return await Bun.file(packageJsonPath).json();
} catch {
@@ -57,7 +58,7 @@ async function main() {
} catch {
npmPackage = "unsupported";
}
console.log("fff - Fast File Finder");
console.log(`Package version: ${pkg.version}`);
console.log("");
@@ -87,15 +88,14 @@ async function main() {
break;
}
case "help":
case "--help":
case "-h":
default: {
const pkg = await getPackageInfo();
console.log(`fff - Fast File Finder CLI v${pkg.version}`);
console.log("");
console.log("Usage:");
console.log(" bunx fff download [tag] Download native binary from GitHub (fallback)");
console.log(
" bunx fff download [tag] Download native binary from GitHub (fallback)",
);
console.log(" bunx fff info Show platform and binary info");
console.log(" bunx fff version Show version");
console.log(" bunx fff help Show this help message");
@@ -104,7 +104,9 @@ async function main() {
console.log(" bunx fff download Download latest binary from GitHub");
console.log(" bunx fff download abc1234 Download specific release tag");
console.log("");
console.log("Note: Binaries are normally provided via platform-specific npm packages.");
console.log(
"Note: Binaries are normally provided via platform-specific npm packages.",
);
console.log("The download command is a fallback for when those aren't available.");
break;
}

Some files were not shown because too many files have changed in this diff Show More