Compare commits

...

62 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 9adc2d207b chore: Update docs for - feat: Nodejs FFI package
docs / docs (push) Has been cancelled
2026-03-18 15:23:14 -07:00
Dmitriy Kovalenko a03946740f feat: Nodejs FFI package 2026-03-17 21:03:18 -07:00
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
Dmitriy Kovalenko 434344f6e9 feat: Prebuilt binaries for bun (#234)
* feat: Prebuilt binaries for bun

* chore: Update docs for - feat: Prebuilt binaries for bun
2026-02-16 20:10:48 -08:00
Dmitriy Kovalenko 6a3e481175 fix: Only use static linking libraries on windows (#232)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/228
2026-02-16 12:12:51 -08:00
Dmitriy Kovalenko 29e13ac3d4 feat: Live grep (#231)
* feat: Live grep

* chore: Update docs for - feat: Live grep

* chore: Update docs for - chore: Update docs for - feat: Live grep

* fix formatting

* chore: Update docs for - fix formatting
2026-02-15 21:42:42 -08:00
Dmitriy Kovalenko c9137b19b6 fix: Windows long path prefix (#226)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/221
2026-02-12 00:39:07 -08:00
Dmitriy Kovalenko 00019beb0c feat: Query parsing (HUGE THING READ CHANGELOG) (#224)
* query parsing

* chore: Update docs for - query parsing

* query parsing

* Bun ffi

* chore: Update docs for - Bun ffi

* fix cross + zig relation

* chore: Update docs for - fix cross + zig relation

* skip windows build

* publish the release

* window

* correct linking for windows dll cdylib

* chore: Update docs for - correct linking for windows dll cdylib

* feat: Add aarch64 windows target

* improve ffi str allocation

* fix aarch windows

* fix nix

* improve allocations

* chore: Update docs for - improve allocations

* fix style
2026-02-11 23:58:11 -08:00
Dmitriy Kovalenko d7bc72786d feat: Advanced path shortening strategies (#222)
* feat: Advanced path shortening strategies

Implements a lot of differnet path displaying shorting strategies. Now
you will see paths like path/.4./next_path and it is configurable

## New strategies

- middle: "some/path/./next/path", "some/path/../next/path" showing max
3 dots in the middle
- middle_number: same as middle but starting from the 4 paths skipped
  shows the number of skipped components
- end: preserves the start of the paths but truncaates not fitting end

* chore: Update docs for - feat: Advanced path shortening strategies
2026-02-07 13:08:14 -08:00
Lord Sinquaad 9a6d8ca81a fix(picker_ui.lua): Vim E33 fnamemodify error in picker_ui.lua (possible fix for #148) (#219)
* fix: Vim E33 fnamemodify error in picker_ui.lua (possible fix for #148)

The ~ file path error seems to only show up when running File picker in
the file buffer itself, does not happen when in for exmaple an Oil file
tree.

* Fix stylua and add comment

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-02-07 12:59:56 -08:00
MatheusNSantiago 6455ce7c68 docs: Document file filtering with .ignore files (#220)
Add documentation explaining that FFF.nvim respects .gitignore patterns
and how to use .ignore files for filtering without modifying .gitignore.
2026-02-07 12:47:00 -08:00
MustafaAamir 0523fe39ff fix(core.lua): Defer vim.notify call in libuv eventloop using vim.sch… (#170)
* fix(core.lua): Defer vim.notify call in libuv eventloop using vim.schedule

* fixed formatting
2026-01-25 06:42:12 -08:00
Marc Jakobi 6b01f95ca6 feat!: add all items to quickfix list if none are selected (#216) 2026-01-24 12:56:38 -08:00
Dmitriy Kovalenko 5ab271ea9d chore: Update rust to 1.94 (#214) 2026-01-08 23:25:45 -08:00
Dmitriy Kovalenko 448cf3d025 fix: Vendor libgit2 + improve checkhealth (#213)
Make sure that libgit2 is vendored so it is statically linked with a
binary and we do not have a dynamic linking
2026-01-08 23:03:23 -08:00
Dmitriy Kovalenko 3cc7da787f feat: Better keymaps for normal mode (try <leader>l & <leader>p) (#212)
This implements significantly better keyboard shortctus for the normal
mode allowing users to close the picker with the same keymap and even
jump into the other fff's windows
2026-01-08 21:37:28 -08:00
Dmitriy Kovalenko 8b1f3f4e95 fix: Fully support winborder setting (#211)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/195
2026-01-08 20:54:42 -08:00
Dmitriy Kovalenko ca4c32d364 fix: show_file_info option will work not and moved under debug (#210)
* fix: show_file_info option will work not and moved under debug

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

* chore: Update docs for - fix: show_file_info option will work not and moved under debug
2026-01-08 19:38:00 -08:00
Dmitriy Kovalenko f6af8353c3 fix: Download lua script (#206) 2025-12-18 21:22:50 -08:00
Dmitriy Kovalenko b384bf7dad fix: Not downloadable binary (#205)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/196

This issue was related to the fact that I chagned vim.mkdir to
vim.uv.fs_mkdir but it is not recursive so when you installed a fresh
version it never created the dir for it.
2025-12-18 18:08:34 -08:00
Dmitriy Kovalenko 2951756ae3 fix: Restore scroll position after last match result showing (#204)
* fix: Scroll position after last match result showing

* chore: Update docs for - fix: Scroll position after last match result showing

* fix: Scroll position after last match result showing
2025-12-18 17:17:01 -08:00
Mohamed Ibraheem c17056bcb6 fix: directory_path highlight is not working if there are no icons (#203) 2025-12-18 17:10:01 -08:00
Dmitriy Kovalenko abfa5d0ef7 feat: Add config param for git based filename highlighting (#202)
* feat: Add config param for git based filename highlighting

* chore: Update docs for - feat: Add config param for git based filename highlighting
2025-12-16 17:34:27 -08:00
Dmitriy Kovalenko d997344fd7 fix: Invert pagination scrollbar when the prompt is in the bottom (#201) 2025-12-16 13:16:45 -08:00
Dmitriy Kovalenko e3ba972db6 fix: Pagination layout when combo box renderer used (#200) 2025-12-16 13:13:24 -08:00
Dmitriy Kovalenko a4f87bd4f2 fix: Do not rely on the global cpath (#199)
As appeared package.cpath is global and other plugins can pollute it so
we can not rely on this. Specifcially codesnap.nvim is breaking fff
2025-12-15 16:56:48 -08:00
Dmitriy Kovalenko 7fd361a369 feat: Pagination (#194)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/83
2025-12-15 16:37:37 -08:00
Dmitriy Kovalenko c477f12487 chore: Remove not-required notifications (#198) 2025-12-15 14:08:01 -08:00
Frank Ebel 8fe26ad4bd docs: fix installation guide for vim.pack (#197) 2025-12-15 10:13:43 -08:00
Dmitriy Kovalenko 003c05cf6d feat: Multi-select and sending to quickfix (#193) 2025-12-14 17:53:05 -08:00
169 changed files with 34453 additions and 4928 deletions
+4 -15
View File
@@ -1,10 +1,4 @@
[target.x86_64-apple-darwin]
rustflags = [
"-C", "link-arg=-undefined",
"-C", "link-arg=dynamic_lookup",
]
[target.aarch64-apple-darwin]
[target.'cfg(target_os = "macos")']
rustflags = [
"-C", "link-arg=-undefined",
"-C", "link-arg=dynamic_lookup",
@@ -16,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"]
+112
View File
@@ -0,0 +1,112 @@
name: e2e Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
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: 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:
include:
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
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 --features zlob
- name: Copy binary to target/release (Windows)
if: matrix.target
shell: bash
run: |
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
- name: Verify Windows DLL has no unexpected dependencies
if: matrix.target
shell: pwsh
run: |
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
$dumpbin = Get-ChildItem "$vsPath" -Recurse -Filter "dumpbin.exe" | Select-Object -First 1
if (-not $dumpbin) { Write-Error "dumpbin.exe not found"; exit 1 }
$deps = & $dumpbin.FullName /DEPENDENTS target\release\fff_nvim.dll | Out-String
Write-Host $deps
# zlob must be statically linked - fail if zlob.dll appears as a dependency
if ($deps -match 'zlob\.dll') {
Write-Error "fff_nvim.dll has unexpected dynamic dependency on zlob.dll - zlob should be statically linked"
exit 1
}
- name: Build Rust binary
if: ${{ !matrix.target }}
run: cargo build --release -p fff-nvim --features zlob
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: v0.10.4
- name: Clone plenary.nvim
shell: bash
run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ../plenary.nvim
- name: Run Lua tests
shell: bash
run: make test-lua
- name: Run bun tests
shell: bash
if: ${{ matrix.os == 'macos-latest' }}
run: make test-bun
- name: Install Node.js
if: ${{ matrix.os != 'ubuntu-latest' }}
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install node deps
if: ${{ matrix.os != 'ubuntu-latest' }}
shell: bash
run: npm install
- name: Run node tests
if: ${{ matrix.os != 'ubuntu-latest' }}
shell: bash
run: make test-node
+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
+2 -2
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
@@ -32,4 +32,4 @@ jobs:
- uses: stefanzweifel/git-auto-commit-action@v6
with:
commit_author: ${{ steps.last-commit.outputs.author }}
commit_message: chore: Update docs for - ${{ steps.last-commit.outputs.message }}
commit_message: "chore: Update docs for - ${{ steps.last-commit.outputs.message }}"
+485 -57
View File
@@ -2,11 +2,132 @@ name: Prebuild
on:
push:
branches: [main, feat/prebuild]
branches: [main]
tags:
- "v*"
pull_request:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
name: Build ${{ matrix.target }}
build-nvim:
name: Build Neovim ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
include:
## Linux builds (using cargo-zigbuild)
# Glibc 2.17 (RHEL 7, CentOS 7 compatible)
- 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/libfff_nvim.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.17
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
ext: so
# Musl (statically linked)
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
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
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
ext: dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
ext: dylib
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
ext: dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
ext: dll
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') && !contains(matrix.target, 'android')
run: |
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 --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 --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: nvim-${{ matrix.target }}
path: ${{ matrix.target }}.*
build-c:
name: Build C FFI ${{ matrix.target }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
@@ -14,122 +135,429 @@ jobs:
matrix:
include:
## Linux builds
# Glibc 2.21
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_nvim.so
zigbuild_target: x86_64-unknown-linux-gnu.2.17
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
npm_package: fff-bin-linux-x64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
# Musl 1.2.3
zigbuild_target: aarch64-unknown-linux-gnu.2.17
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_c.so
npm_package: fff-bin-linux-arm64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_c.so
npm_package: fff-bin-linux-x64-musl
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
# # Android (Termux)
# - os: ubuntu-latest
# target: aarch64-linux-android
# artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_c.so
npm_package: fff-bin-linux-arm64-musl
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
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
npm_package: fff-bin-darwin-x64
lib_filename: libfff_c.dylib
ext: dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
artifact_name: target/aarch64-apple-darwin/release/libfff_c.dylib
npm_package: fff-bin-darwin-arm64
lib_filename: libfff_c.dylib
ext: dylib
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
artifact_name: target/x86_64-pc-windows-msvc/release/fff_c.dll
npm_package: fff-bin-win32-x64
lib_filename: fff_c.dll
ext: dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
artifact_name: target/aarch64-pc-windows-msvc/release/fff_c.dll
npm_package: fff-bin-win32-arm64
lib_filename: fff_c.dll
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
# - name: Set Rust toolchain
# if: contains(matrix.target, 'linux')
# # https://github.com/rust-cross/cargo-zigbuild/issues/327
# run: echo -e '[toolchain]\nchannel = "nightly-2025-02-19"' > rust-toolchain.toml
- name: Install Rust
run: |
# https://github.com/rust-cross/cargo-zigbuild/issues/327
rustup toolchain install nightly-2025-02-19
rustup default nightly-2025-02-19
rustup target add ${{ matrix.target }}
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')
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo install cross --git https://github.com/cross-rs/cross
cross build --release --target ${{ matrix.target }}
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.so"
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: |
# Ventura (https://en.wikipedia.org/wiki/MacOS_version_history#Releases)
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }}
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.dylib"
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 }}
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.dll"
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Upload artifacts
- name: Prepare npm package
if: "!contains(matrix.target, 'android')"
shell: bash
run: |
# Copy the built binary into the platform npm package directory
cp "c-lib-${{ matrix.target }}.${{ matrix.ext }}" "packages/${{ matrix.npm_package }}/${{ matrix.lib_filename }}"
- name: Upload C library artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: ${{ matrix.target }}*
name: c-lib-${{ matrix.target }}
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
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
with:
path: ./binaries
- name: Flatten and rename Neovim artifacts
working-directory: ./binaries
run: |
# Move nvim artifacts to root level with original naming
for dir in nvim-*/; do
target="${dir#nvim-}"
target="${target%/}"
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: Flatten C library artifacts
working-directory: ./binaries
run: |
# Move c-lib artifacts to root level
for dir in c-lib-*/; 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: 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: |
rm -rf npm-*
- name: Generate checksums
working-directory: ./binaries
run: |
ls -a
for file in ./**/*; do
sha256sum "$file" > "${file}.sha256"
ls -la
for file in *; do
if [ -f "$file" ] && [[ ! "$file" == *.sha256 ]]; then
sha256sum "$file" > "${file}.sha256"
fi
done
- name: Prepare tag
id: vars
shell: bash
run: |
sha="$(git rev-parse --short HEAD)"
echo "tag=$sha" >> $GITHUB_OUTPUT
- name: Determine version
id: version
run: ./scripts/determine-version.sh
- name: Upload Release Assets
uses: softprops/action-gh-release@v2
with:
name: "${{ steps.vars.outputs.tag }}"
tag_name: "${{ steps.vars.outputs.tag }}"
name: "${{ steps.version.outputs.version }}"
tag_name: "${{ steps.version.outputs.is_release == 'true' && format('v{0}', steps.version.outputs.version) || steps.version.outputs.version }}"
token: ${{ github.token }}
files: ./binaries/**/*
files: ./binaries/*
draft: false
prerelease: true
generate_release_notes: false
prerelease: ${{ steps.version.outputs.is_release != 'true' }}
generate_release_notes: ${{ steps.version.outputs.is_release == 'true' }}
body: |
Nightly release from commit: ${{ github.sha }}
${{ steps.version.outputs.is_release == 'true' && format('Release {0}', steps.version.outputs.version) || format('Nightly release from commit: {0}', github.sha) }}
## Neovim Plugin
- `{target}.so` / `.dylib` / `.dll` - Lua module for Neovim
## 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
```
crates-publish:
name: Publish Rust crates
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/node' || startsWith(github.ref, 'refs/tags/v')))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'node'))
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-edit
run: cargo install cargo-edit
- name: Determine version
id: version
run: ./scripts/determine-version.sh
- name: Publish crates
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: make publish-crates V="${{ steps.version.outputs.version }}"
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/node' || startsWith(github.ref, 'refs/tags/v')))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'node'))
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "25"
registry-url: "https://registry.npmjs.org"
- name: Determine version
id: version
run: ./scripts/determine-version.sh
- name: Download npm package artifacts
uses: actions/download-artifact@v4
with:
pattern: npm-*
path: ./npm-packages
- name: Publish platform packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
for pkg_dir in ./npm-packages/npm-*/; do
if [ -d "$pkg_dir" ]; then
pkg_name=$(node -p "require('${pkg_dir}package.json').name")
echo "Publishing ${pkg_name}@${VERSION} with tag ${TAG}..."
make set-npm-version PKG="$pkg_dir" VERSION="$VERSION"
cd "$pkg_dir"
npm publish --tag "$TAG" --access public || echo "Failed to publish ${pkg_name} (may already exist)"
cd -
fi
done
- name: Publish bun package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/fff-bun@${VERSION} with tag ${TAG}..."
make set-npm-version PKG=packages/fff-bun VERSION="$VERSION"
cd packages/fff-bun
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-bun (may already exist)"
- name: Publish Node.js package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/fff-node@${VERSION} with tag ${TAG}..."
make set-npm-version PKG=packages/fff-node VERSION="$VERSION"
cd packages/fff-node
npm install
npx tsc
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-node (may already exist)"
+25 -10
View File
@@ -8,6 +8,9 @@ on:
env:
CARGO_TERM_COLOR: always
# Ensure consistent macOS deployment target across all compiled objects
# (Rust, cc-compiled C code, and Zig-compiled zlob) to avoid linker warnings
MACOSX_DEPLOYMENT_TARGET: "13"
jobs:
test:
@@ -15,28 +18,32 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, macos-latest]
steps:
- name: Install Lua
if: matrix.os == 'macos-latest'
run: brew install lua
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
cache-key: "v1-rust"
components: rustfmt, clippy
target: wasm32-unknown-unknown
- name: Run tests
run: cargo test --verbose
run: cargo test --features zlob --workspace --exclude fff-nvim
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:
@@ -49,11 +56,19 @@ 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: goto-bus-stop/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy
- name: Run clippy
run: cargo clippy -- -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
+887 -123
View File
File diff suppressed because it is too large Load Diff
+41 -42
View File
@@ -1,62 +1,61 @@
[package]
name = "fff_nvim"
version = "0.1.0"
edition = "2024"
[workspace]
members = [
"crates/fff-c",
"crates/fff-core",
"crates/fff-mcp",
"crates/fff-nvim",
"crates/fff-query-parser",
"crates/fff-grep",
]
resolver = "2"
[lib]
path = "lua/fff/rust/lib.rs"
crate-type = ["cdylib", "rlib"]
[workspace.dependencies]
fff-grep = { version = "0.2.3", path = "crates/fff-grep" }
fff-query-parser = { version = "0.2.3", path = "crates/fff-query-parser", default-features = false }
[[bin]]
name = "test_watcher"
path = "src/bin/test_watcher.rs"
[[bin]]
name = "jemalloc_profile"
path = "src/bin/jemalloc_profile.rs"
[[bin]]
name = "search_profiler"
path = "src/bin/search_profiler.rs"
[[bin]]
name = "bench_search_only"
path = "src/bin/bench_search_only.rs"
[[bench]]
name = "query_tracker_bench"
harness = false
[dependencies]
# Shared dependencies
ahash = "0.8"
bindet = "0.3"
blake3 = "1.8.2"
chrono = { version = "0.4", features = ["serde"] }
ctrlc = "3.4.2"
git2 = "0.20.2"
dirs = "5.0"
dunce = "1.0"
# git2 - base config without TLS (each crate adds platform-specific TLS)
git2 = { version = "0.20.2", default-features = false, features = [
"vendored-libgit2",
] }
glidesort = "0.1"
globset = "0.4"
grep-matcher = "0.1.8"
heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
zlob = "1.3.0"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.7.1" }
neo_frizbee = "0.8.2"
notify = "8.1.0"
notify-debouncer-full = "0.6"
notify-debouncer-full = "0.7"
once_cell = "1.20.2"
openssl = { version = "0.10", features = ["vendored"] }
parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
serde = { version = "1.0", features = ["derive"] }
smartstring = { version = "1.0.1", features = ["serde"] }
regex = "1.11"
smallvec = { version = "1.13", features = [
"const_generics",
"union",
"may_dangle",
] }
thiserror = "2.0.10"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand = { version = "0.8", features = ["small_rng"] }
tempfile = "3.8"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = true
[[bench]]
name = "indexing_and_search"
harness = false
[profile.bench]
inherits = "release"
-7
View File
@@ -1,7 +0,0 @@
# Picked glibc 2.21 to support Ubuntu 14.04+
[target.x86_64-unknown-linux-gnu]
zig = "2.17"
[target.aarch64-unknown-linux-gnu]
zig = "2.17"
+92
View File
@@ -0,0 +1,92 @@
PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-bun test-node prepare-bun prepare-node set-npm-version header
build:
cargo build --release --features zlob
header:
cbindgen --config crates/fff-c/cbindgen.toml --crate fff-c --output crates/fff-c/include/fff.h
test-setup:
@if [ ! -d "$(PLENARY_DIR)" ]; then \
echo "Cloning plenary.nvim..."; \
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(PLENARY_DIR); \
fi
test-rust:
cargo test --workspace --features zlob --exclude fff-nvim
test-lua: test-setup build
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
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
prepare-node: build
mkdir -p packages/fff-node/bin
cp target/release/libfff_c.dylib packages/fff-node/bin/ 2>/dev/null; \
cp target/release/libfff_c.so packages/fff-node/bin/ 2>/dev/null; \
cp target/release/fff_c.dll packages/fff-node/bin/ 2>/dev/null; \
true
test-bun: prepare-bun
cd packages/fff-bun && bun test src/
test-node: prepare-node
cd packages/fff-node && npm run build && node test/e2e.mjs
test: test-rust test-lua test-bun test-node
# Update version in a package.json, including optionalDependencies.
# Usage: make set-npm-version PKG=packages/fff-bun VERSION=1.0.0-nightly.abc1234
set-npm-version:
@test -n "$(PKG)" || (echo "PKG is required" && exit 1)
@test -n "$(VERSION)" || (echo "VERSION is required" && exit 1)
node -e " \
const fs = require('fs'); \
const pkg = JSON.parse(fs.readFileSync('$(PKG)/package.json', 'utf8')); \
pkg.version = '$(VERSION)'; \
if (pkg.optionalDependencies) { \
for (const dep of Object.keys(pkg.optionalDependencies)) { \
pkg.optionalDependencies[dep] = '$(VERSION)'; \
} \
} \
fs.writeFileSync('$(PKG)/package.json', JSON.stringify(pkg, null, 2) + '\n'); \
"
@echo "Set $(PKG) to $(VERSION)"
format-rust:
cargo fmt --all
format-lua:
stylua .
format-ts:
bun format
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
CRATES_TO_PUBLISH= fff-grep fff-query-parser fff-search
publish-crates:
@test -n "$(V)" || (echo "V is required. Usage: make publish-crates V=0.2.0" && exit 1)
cargo install cargo-edit
cargo set-version $(V) || exit 1;
@for crate in $(CRATES_TO_PUBLISH); do \
cargo publish -p $$crate --allow-dirty $$(if [ -n "$$CI" ]; then echo "--no-verify"; fi) || exit 1; \
done
+274 -77
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;">
@@ -11,39 +15,47 @@
<img alt="Stars" src="https://img.shields.io/github/stars/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41"></a>
<a href="https://github.com/dmtrKovalenko/fff.nvim/issues" style="text-decoration: none">
<img alt="Issues" src="https://img.shields.io/github/issues/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=bilibili&color=F5E0DC&logoColor=D9E0EE&labelColor=302D41"></a>
<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>
<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
@@ -70,7 +82,26 @@ FFF.nvim requires:
"ff", -- try it if you didn't it is a banger keybinding for a picker
function() require('fff').find_files() end,
desc = 'FFFind files',
}
},
{
"fg",
function() require('fff').live_grep() end,
desc = 'LiFFFe grep',
},
{
"fz",
function() require('fff').live_grep({
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',
},
}
}
```
@@ -80,7 +111,7 @@ FFF.nvim requires:
```lua
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
nvim.create_autocmd('PackChanged', {
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
require('fff.download').download_or_build_binary()
@@ -123,6 +154,17 @@ 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)
-- 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,
@@ -131,8 +173,8 @@ 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,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
@@ -151,20 +193,32 @@ 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>',
-- 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',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
@@ -186,8 +240,16 @@ require('fff').setup({
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
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
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
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
-- Store file open frecency
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
@@ -196,19 +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,
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 = 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
@@ -216,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
@@ -236,11 +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:
@@ -249,6 +322,163 @@ Toggle scoring information display:
- Use `:FFFDebug` command
- Enable by default with `debug.show_scores = true`
#### Multi-Select and Quickfix Integration
Select multiple files and send them to Neovim's quickfix list (keymaps are configurable):
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
- `<C-q>` - Send selected files to quickfix list and close picker
#### Live Grep Search Modes
Live grep supports three search modes, cycled with `<S-Tab>`:
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
The current mode is shown on the right side of the input field (e.g., `plain`, `regex`, `fuzzy`) with color-coded highlighting.
You can customize which modes are available and their cycling order globally in your configuration, or per-call when invoking `live_grep()`.
**Global configuration:**
```lua
require('fff').setup({
grep = {
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
}
})
```
**Per-call configuration:**
```lua
-- Only fuzzy and plain modes for this specific grep
require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' },
}
})
-- Single mode (hides mode indicator completely)
require('fff').live_grep({
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:
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
- **Grep search with no matches** → shows suggested **file name matches** for the same query
Suggestions are clearly labeled with a "No results found. Suggested ..." banner (highlighted with `hl.suggestion_header`). You can navigate and select suggestion items just like normal results — selecting a grep suggestion will open the file at the matching line.
#### Git Status Highlighting
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
```lua
hl = {
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
}
```
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
To enable git status text coloring, set `git.status_text_color = true`:
```lua
require('fff').setup({
git = {
status_text_color = true, -- Enable git status colors on filename text
},
hl = {
git_staged = 'FFFGitStaged', -- Files staged for commit
git_modified = 'FFFGitModified', -- Modified unstaged files
git_deleted = 'FFFGitDeleted', -- Deleted files
git_renamed = 'FFFGitRenamed', -- Renamed files
git_untracked = 'FFFGitUntracked', -- New untracked files
git_ignored = 'FFFGitIgnored', -- Git-ignored files
}
})
```
The plugin provides sensible default highlight groups that link to common git highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these with your own custom highlight groups to match your colorscheme.
**Example - Custom Bright Colors for Text:**
```lua
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
require('fff').setup({
git = {
status_text_color = true,
},
hl = {
git_modified = 'CustomGitModified',
git_untracked = 'CustomGitUntracked',
}
})
```
#### File Filtering
FFF.nvim respects `.gitignore` patterns automatically. To filter files from the picker without modifying `.gitignore`, create a `.ignore` file in your project root:
```gitignore
# Exclude all markdown files
*.md
# Exclude specific subdirectory
docs/archive/**/*.md
```
Run `:FFFScan` to force a rescan if needed.
### Troubleshooting
#### Health Check
@@ -263,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
-485
View File
@@ -1,485 +0,0 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::FilePicker;
use std::path::PathBuf;
use std::time::Duration;
use tracing_subscriber;
/// Initialize tracing to output to console
fn init_tracing() {
// use tracing_subscriber::EnvFilter;
// use tracing_subscriber::fmt;
// let _ = fmt()
// .with_env_filter(
// EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
// )
// .with_target(false)
// .with_thread_ids(true)
// .with_line_number(true)
// .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(())
}
/// Helper function to wait for scanning to complete and get file count
fn wait_for_scan_completion(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();
let mut iteration = 0;
loop {
iteration += 1;
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let is_scanning = picker.is_scan_active();
let file_count = picker.get_files().len();
// Log progress every 2 seconds
if last_log.elapsed() >= Duration::from_secs(2) {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}, Iterations: {}",
start.elapsed().as_secs_f32(),
is_scanning,
file_count,
iteration
);
last_log = std::time::Instant::now();
}
if !is_scanning && file_count > 0 {
eprintln!(
" ✓ Scan complete after {:.2}s: {} files found",
start.elapsed().as_secs_f32(),
file_count
);
return Ok(file_count);
}
} else {
if iteration % 100 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None (iteration {})",
start.elapsed().as_secs_f32(),
iteration
);
}
}
}
if start.elapsed() > timeout {
return Err(format!(
"Scan timed out after {} seconds (iteration {})",
timeout_secs, iteration
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Get files from the global FILE_PICKER
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().to_vec())
} else {
Err("FilePicker not initialized".to_string())
}
}
/// Clean up global state
fn cleanup_global_state() {
if let Ok(mut picker_guard) = FILE_PICKER.write() {
if let Some(mut picker) = picker_guard.take() {
picker.stop_background_monitor();
}
}
}
/// Initialize FilePicker once and return files snapshot
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
}
let canonical_path = big_repo_path
.canonicalize()
.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());
}
}
}
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
init_file_picker_internal(&canonical_path.to_string_lossy())?;
eprintln!(" Waiting for background scan to complete...");
let file_count = wait_for_scan_completion(120)?;
eprintln!(
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
file_count
);
get_files_snapshot()
}
/// Benchmark for indexing the big-repo directory
fn bench_indexing(c: &mut Criterion) {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = match big_repo_path.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!("⚠ Failed to canonicalize path: {}", e);
return;
}
};
let mut group = c.benchmark_group("indexing");
group.sample_size(10);
group.measurement_time(Duration::from_secs(20));
group.bench_function("index_big_repo", |b| {
b.iter(|| {
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
let start = std::time::Instant::now();
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
.expect("Failed to init FilePicker");
match wait_for_scan_completion(120) {
Ok(file_count) => {
let elapsed = start.elapsed();
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
file_count
}
Err(e) => {
eprintln!(" ✗ Error: {}", e);
0
}
}
});
});
group.finish();
}
/// Benchmark for searching with various query patterns
fn bench_search_queries(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprint!("Failed to setup picker {e:?}");
return;
}
};
let mut group = c.benchmark_group("search");
group.sample_size(100);
let test_queries = vec![
("short", "mod"),
("medium", "controller"),
("long", "user_authentication"),
("typo", "contrlr"),
("partial", "src/lib"),
];
for (name, query) in test_queries {
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(100),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search with different thread counts
fn bench_search_thread_scaling(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("thread_scaling");
group.sample_size(100);
let query = "controller";
let thread_counts = vec![1, 2, 4, 8];
for threads in thread_counts {
group.bench_with_input(
BenchmarkId::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(100),
black_box(threads),
black_box(None),
black_box(false),
);
results.total_matched
});
},
);
}
group.finish();
}
/// Benchmark search with different result limits
fn bench_search_result_limits(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("result_limits");
group.sample_size(100);
let query = "mod";
let result_limits = vec![10, 50, 100, 500];
for limit in result_limits {
group.bench_with_input(BenchmarkId::from_parameter(limit), &limit, |b, &limit| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(limit),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search algorithm performance scaling with file count
fn bench_search_scalability(c: &mut Criterion) {
let all_files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
return;
}
};
if all_files.len() < 1000 {
eprintln!(
"⚠ Skipping scalability benchmark: need at least 1000 files, got {}",
all_files.len()
);
return;
}
let mut group = c.benchmark_group("search_scalability");
group.sample_size(50);
let query = "controller";
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
for count in file_counts {
if count > all_files.len() {
continue;
}
let subset = &all_files[..count];
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, _| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
black_box(100),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search performance with different ordering modes
fn bench_search_ordering(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
let query = "controller";
// 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(100),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
// Benchmark reverse order (ascending)
group.bench_function("reverse_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(100),
black_box(4),
black_box(None),
black_box(true),
);
results.total_matched
});
});
// Benchmark with large result set
group.bench_function("normal_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
black_box(500),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
group.bench_function("reverse_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
black_box(500),
black_box(4),
black_box(None),
black_box(true),
);
results.total_matched
});
});
// Benchmark with small result set
group.bench_function("normal_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
black_box(10),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
group.bench_function("reverse_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
black_box(10),
black_box(4),
black_box(None),
black_box(true),
);
results.total_matched
});
});
group.finish();
}
criterion_group!(
benches,
bench_indexing,
bench_search_queries,
bench_search_thread_scaling,
bench_search_result_limits,
bench_search_scalability,
bench_search_ordering,
);
criterion_main!(benches);
+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"
}
}
}
}
+148
View File
@@ -0,0 +1,148 @@
{
"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-node": {
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"bin": {
"fff-node": "./dist/scripts/cli.js",
},
"dependencies": {
"ffi-rs": "^1.0.0",
},
"devDependencies": {
"@types/node": "^22.0.0",
"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",
},
},
},
"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-node": ["@ff-labs/fff-node@workspace:packages/fff-node"],
"@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@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ=="],
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q=="],
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg=="],
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A=="],
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA=="],
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA=="],
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.1", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg=="],
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw=="],
"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=="],
"ffi-rs": ["ffi-rs@1.3.1", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-x64": "1.3.1", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.1", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.1", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-x64-musl": "1.3.1", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.1", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.1", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.1" } }, "sha512-ZyNXL9fnclnZV+waQmWB9JrfbIEyxQa1OWtMrHOrAgcC04PgP5hBMG5TdhVN8N4uT/eul8zCFMVnJUukAFFlXA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"bun-types/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+23
View File
@@ -0,0 +1,23 @@
[package]
name = "fff-c"
version = "0.2.4"
edition = "2024"
description = "Raw C api of FFF file finder"
license = "MIT"
[lib]
crate-type = ["cdylib"]
[features]
default = []
zlob = ["fff/zlob"]
[dependencies]
mimalloc.workspace = true
tracing.workspace = true
git2.workspace = true
fff = { package = "fff-search", path = "../fff-core" , version = "0.2.2" }
fff-query-parser = { path = "../fff-query-parser" , version = "0.2.4" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+28
View File
@@ -0,0 +1,28 @@
language = "C"
header = "/* Generated by cbindgen — do not edit manually. */"
include_guard = "FFF_C_H"
include_version = true
no_includes = true
sys_includes = ["stdint.h", "stdbool.h", "stddef.h"]
[export]
include = [
"FffResult",
"FffSearchResult", "FffFileItem", "FffScore", "FffLocation",
"FffGrepResult", "FffGrepMatch", "FffMatchRange",
"FffScanProgress",
]
[export.rename]
"FffResult" = "FffResult"
"FffSearchResult" = "FffSearchResult"
"FffFileItem" = "FffFileItem"
"FffScore" = "FffScore"
"FffLocation" = "FffLocation"
"FffGrepResult" = "FffGrepResult"
"FffGrepMatch" = "FffGrepMatch"
"FffMatchRange" = "FffMatchRange"
"FffScanProgress" = "FffScanProgress"
[fn]
sort_by = "None"
+539
View File
@@ -0,0 +1,539 @@
/* Generated by cbindgen — do not edit manually. */
#ifndef FFF_C_H
#define FFF_C_H
/* Generated with cbindgen:0.29.2 */
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
/**
* Result envelope returned by all `fff_*` functions.
*
* Heap-allocated — the caller must free it with `fff_free_result`.
*
* Depending on the function, the payload is delivered through different fields:
*
* | Function | Payload field | Type |
* |----------------------------|---------------|-------------------------------|
* | `fff_create_instance` | `handle` | opaque instance pointer |
* | `fff_search` | `handle` | `*mut FffSearchResult` |
* | `fff_live_grep` | `handle` | `*mut FffGrepResult` |
* | `fff_multi_grep` | `handle` | `*mut FffGrepResult` |
* | `fff_get_scan_progress` | `handle` | `*mut FffScanProgress` |
* | `fff_health_check` | `handle` | `*mut c_char` (JSON string) |
* | `fff_get_historical_query` | `handle` | `*mut c_char` (string or null)|
* | `fff_wait_for_scan` | `int_value` | 1 = completed, 0 = timed out |
* | `fff_track_query` | `int_value` | 1 = success, 0 = failure |
* | `fff_refresh_git_status` | `int_value` | number of files updated |
* | `fff_scan_files` | (none) | success flag only |
* | `fff_restart_index` | (none) | success flag only |
*
* On failure, `success` is false and `error` contains the message.
*
* **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
* The caller must free the handle with the appropriate function
* (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
* `fff_free_string`, etc.).
*/
typedef struct FffResult {
/**
* Whether the operation succeeded.
*/
bool success;
/**
* Error message on failure. Null on success.
*/
char *error;
/**
* Opaque pointer payload (instance handle, typed result struct, or string). May be null.
*/
void *handle;
/**
* Integer payload for simple return values (bool as 0/1, counts, etc.).
*/
int64_t int_value;
} FffResult;
/**
* A file item returned by `fff_search`.
*
* All string fields are heap-allocated and owned by the parent `FffSearchResult`.
* Free the entire result with `fff_free_search_result`.
*/
typedef struct FffFileItem {
char *path;
char *relative_path;
char *file_name;
char *git_status;
uint64_t size;
uint64_t modified;
int64_t access_frecency_score;
int64_t modification_frecency_score;
int64_t total_frecency_score;
bool is_binary;
} FffFileItem;
/**
* Score breakdown for a search result.
*/
typedef struct FffScore {
int32_t total;
int32_t base_score;
int32_t filename_bonus;
int32_t special_filename_bonus;
int32_t frecency_boost;
int32_t distance_penalty;
int32_t current_file_penalty;
int32_t combo_match_boost;
bool exact_match;
char *match_type;
} FffScore;
/**
* Location parsed from a query string (e.g. `"file.ts:42:10"`).
*
* `tag` encodes the variant:
* 0 = no location,
* 1 = line only (`line` is set),
* 2 = position (`line` + `col`),
* 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
*/
typedef struct FffLocation {
uint8_t tag;
int32_t line;
int32_t col;
int32_t end_line;
int32_t end_col;
} FffLocation;
/**
* Search result returned by `fff_search`.
*
* The caller must free this with `fff_free_search_result`.
*/
typedef struct FffSearchResult {
/**
* Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
*/
struct FffFileItem *items;
/**
* Pointer to a heap-allocated array of `FffScore` (length = `count`).
*/
struct FffScore *scores;
/**
* Number of items/scores in the arrays.
*/
uint32_t count;
/**
* Total number of files that matched the query.
*/
uint32_t total_matched;
/**
* Total number of indexed files.
*/
uint32_t total_files;
/**
* Location parsed from the query string.
*/
struct FffLocation location;
} FffSearchResult;
/**
* A byte range within a matched line, used for highlighting.
*/
typedef struct FffMatchRange {
uint32_t start;
uint32_t end;
} FffMatchRange;
/**
* A single grep match with file and line information.
*
* All string fields and arrays are heap-allocated. Free the parent
* `FffGrepResult` with `fff_free_grep_result` to release everything.
*/
typedef struct FffGrepMatch {
char *path;
char *relative_path;
char *file_name;
char *git_status;
char *line_content;
struct FffMatchRange *match_ranges;
char **context_before;
char **context_after;
uint64_t size;
uint64_t modified;
int64_t total_frecency_score;
int64_t access_frecency_score;
int64_t modification_frecency_score;
uint64_t line_number;
uint64_t byte_offset;
uint32_t col;
uint32_t match_ranges_count;
uint32_t context_before_count;
uint32_t context_after_count;
uint16_t fuzzy_score;
bool has_fuzzy_score;
bool is_binary;
bool is_definition;
} FffGrepMatch;
/**
* Grep result returned by `fff_live_grep` and `fff_multi_grep`.
*
* The caller must free this with `fff_free_grep_result`.
*/
typedef struct FffGrepResult {
/**
* Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
*/
struct FffGrepMatch *items;
/**
* Number of matches in the `items` array.
*/
uint32_t count;
/**
* Total number of matches (always equal to `count`).
*/
uint32_t total_matched;
/**
* Number of files actually opened and searched in this call.
*/
uint32_t total_files_searched;
/**
* Total number of indexed files (before any filtering).
*/
uint32_t total_files;
/**
* Number of files eligible for search after filtering.
*/
uint32_t filtered_file_count;
/**
* File offset for the next page. 0 if all files have been searched.
*/
uint32_t next_file_offset;
/**
* Regex compilation error when falling back to literal matching. Null if none.
*/
char *regex_fallback_error;
} FffGrepResult;
/**
* Scan progress returned by `fff_get_scan_progress`.
*
* The caller must free this with `fff_free_scan_progress`.
*/
typedef struct FffScanProgress {
uint64_t scanned_files_count;
bool is_scanning;
} FffScanProgress;
/**
* Create a new file finder instance.
*
* Returns an opaque pointer that must be passed to all other `fff_*` calls
* and eventually freed with `fff_destroy`.
*
* # Parameters
*
* * `base_path` directory to index (required)
* * `frecency_db_path` path to frecency LMDB database (NULL/empty to skip)
* * `history_db_path` path to query history LMDB database (NULL/empty to skip)
* * `use_unsafe_no_lock` use MDB_NOLOCK for LMDB (useful in single-process setups)
* * `warmup_mmap_cache` pre-populate mmap caches after the initial scan
* * `ai_mode` enable AI-agent optimizations (auto-track frecency on modifications)
*
* ## Safety
* String parameters must be valid null-terminated UTF-8 or NULL.
*/
struct FffResult *fff_create_instance(const char *base_path,
const char *frecency_db_path,
const char *history_db_path,
bool use_unsafe_no_lock,
bool warmup_mmap_cache,
bool ai_mode);
/**
* Destroy a file finder instance and free all its resources.
*
* ## Safety
* `fff_handle` must be a valid pointer returned by `fff_create_instance`, or null (no-op).
*/
void fff_destroy(void *fff_handle);
/**
* Perform fuzzy search on indexed files.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query string
* * `current_file` path of the currently open file for deprioritization (NULL/empty to skip)
* * `max_threads` maximum worker threads (0 = auto-detect)
* * `page_index` pagination offset (0 = first page)
* * `page_size` results per page (0 = default 100)
* * `combo_boost_multiplier` score multiplier for combo matches (0 = default 100)
* * `min_combo_count` minimum combo count before boost applies (0 = default 3)
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` and `current_file` must be valid null-terminated UTF-8 strings or NULL.
*/
struct FffResult *fff_search(void *fff_handle,
const char *query,
const char *current_file,
uint32_t max_threads,
uint32_t page_index,
uint32_t page_size,
int32_t combo_boost_multiplier,
uint32_t min_combo_count);
/**
* Perform content search (grep) across indexed files.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query (supports constraint syntax like `*.rs pattern`)
* * `mode` 0 = plain text (SIMD), 1 = regex, 2 = fuzzy
* * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
* * `max_matches_per_file` max matches per file (0 = unlimited)
* * `smart_case` case-insensitive when query is all lowercase
* * `file_offset` file-based pagination offset (0 = start)
* * `page_limit` max matches to return (0 = default 50)
* * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
* * `before_context` context lines before each match
* * `after_context` context lines after each match
* * `classify_definitions` tag matches that are code definitions
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` must be a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_live_grep(void *fff_handle,
const char *query,
uint8_t mode,
uint64_t max_file_size,
uint32_t max_matches_per_file,
bool smart_case,
uint32_t file_offset,
uint32_t page_limit,
uint64_t time_budget_ms,
uint32_t before_context,
uint32_t after_context,
bool classify_definitions);
/**
* Perform multi-pattern OR search (Aho-Corasick) across indexed files.
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated multi-needle matching.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `patterns_joined` patterns separated by `\n` (e.g. `"foo\nbar\nbaz"`)
* * `constraints` file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip)
* * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
* * `max_matches_per_file` max matches per file (0 = unlimited)
* * `smart_case` case-insensitive when all patterns are lowercase
* * `file_offset` file-based pagination offset (0 = start)
* * `page_limit` max matches to return (0 = default 50)
* * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
* * `before_context` context lines before each match
* * `after_context` context lines after each match
* * `classify_definitions` tag matches that are code definitions
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `patterns_joined` and `constraints` must be valid null-terminated UTF-8 or NULL.
*/
struct FffResult *fff_multi_grep(void *fff_handle,
const char *patterns_joined,
const char *constraints,
uint64_t max_file_size,
uint32_t max_matches_per_file,
bool smart_case,
uint32_t file_offset,
uint32_t page_limit,
uint64_t time_budget_ms,
uint32_t before_context,
uint32_t after_context,
bool classify_definitions);
/**
* Trigger a rescan of the file index.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_scan_files(void *fff_handle);
/**
* Check if a scan is currently in progress.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
bool fff_is_scanning(void *fff_handle);
/**
* Get scan progress information.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_get_scan_progress(void *fff_handle);
/**
* Wait for initial scan to complete.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_wait_for_scan(void *fff_handle, uint64_t timeout_ms);
/**
* Restart indexing in a new directory.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `new_path` must be a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_restart_index(void *fff_handle, const char *new_path);
/**
* Refresh git status cache.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_refresh_git_status(void *fff_handle);
/**
* Track query completion for smart suggestions.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` and `file_path` must be valid null-terminated UTF-8 strings.
*/
struct FffResult *fff_track_query(void *fff_handle, const char *query, const char *file_path);
/**
* Get historical query by offset (0 = most recent).
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_get_historical_query(void *fff_handle, uint64_t offset);
/**
* Get health check information.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`, or null for
* a limited health check (version + git only).
* * `test_path` can be null or a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_health_check(void *fff_handle, const char *test_path);
/**
* Free a search result returned by `fff_search`.
*
* This frees the `FffSearchResult` struct, its `items` and `scores` arrays,
* and all heap-allocated strings within each item and score.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_search`, or null (no-op).
*/
void fff_free_search_result(struct FffSearchResult *result);
/**
* Get a pointer to the `index`-th `FffFileItem` in a search result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the search result is freed.
*
* ## Safety
* `result` must be a valid `FffSearchResult` pointer from `fff_search`.
*/
const struct FffFileItem *fff_search_result_get_item(const struct FffSearchResult *result,
uint32_t index);
/**
* Get a pointer to the `index`-th `FffScore` in a search result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the search result is freed.
*
* ## Safety
* `result` must be a valid `FffSearchResult` pointer from `fff_search`.
*/
const struct FffScore *fff_search_result_get_score(const struct FffSearchResult *result,
uint32_t index);
/**
* Free a grep result returned by `fff_live_grep` or `fff_multi_grep`.
*
* This frees the `FffGrepResult` struct, its `items` array, and all
* heap-allocated strings, match ranges, and context arrays within each match.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_live_grep` or `fff_multi_grep`, or null (no-op).
*/
void fff_free_grep_result(struct FffGrepResult *result);
/**
* Get a pointer to the `index`-th `FffGrepMatch` in a grep result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the grep result is freed.
*
* ## Safety
* `result` must be a valid `FffGrepResult` pointer from `fff_live_grep` or `fff_multi_grep`.
*/
const struct FffGrepMatch *fff_grep_result_get_match(const struct FffGrepResult *result,
uint32_t index);
/**
* Free a scan progress result returned by `fff_get_scan_progress`.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_get_scan_progress`, or null (no-op).
*/
void fff_free_scan_progress(struct FffScanProgress *result);
/**
* Offset a pointer by `byte_offset` bytes.
*
* General-purpose utility for FFI consumers that need pointer arithmetic
* (e.g. iterating over arrays). Returns null if `base` is null.
*
* ## Safety
* The resulting pointer must be within the bounds of the original allocation.
*/
const void *fff_ptr_offset(const void *base, uintptr_t byte_offset);
/**
* Free a result returned by any `fff_*` function.
*
* ## Safety
* `result_ptr` must be a valid pointer returned by a `fff_*` function.
*/
void fff_free_result(struct FffResult *result_ptr);
/**
* Free a string returned by `fff_*` functions.
*
* ## Safety
* `s` must be a valid C string allocated by this library.
*/
void fff_free_string(char *s);
#endif /* FFF_C_H */
+529
View File
@@ -0,0 +1,529 @@
//! FFI-compatible type definitions
//!
//! All result types use `#[repr(C)]` structs for direct memory access from any
//! language with C FFI support. No JSON serialization is used for search or grep
//! results — callers read struct fields directly.
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use fff::git::format_git_status;
use fff::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Allocate a heap CString from a `&str`, returning a raw pointer.
fn cstring_new(s: &str) -> *mut c_char {
CString::new(s).unwrap_or_default().into_raw()
}
/// Convert a `Vec<T>` into a raw pointer + count, leaking the memory.
fn vec_to_raw<T>(v: Vec<T>) -> (*mut T, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let count = v.len() as u32;
let mut boxed = v.into_boxed_slice();
let p = boxed.as_mut_ptr();
std::mem::forget(boxed);
(p, count)
}
/// Convert a `&[String]` into a heap-allocated array of C strings.
fn strings_to_raw(v: &[String]) -> (*mut *mut c_char, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let ptrs: Vec<*mut c_char> = v.iter().map(|s| cstring_new(s)).collect();
vec_to_raw(ptrs)
}
/// Free a heap-allocated array of C strings.
///
/// ## Safety
/// `arr` must have been produced by `strings_to_raw`.
unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) {
if arr.is_null() {
return;
}
unsafe {
let ptrs = Vec::from_raw_parts(arr, count as usize, count as usize);
for p in ptrs {
if !p.is_null() {
drop(CString::from_raw(p));
}
}
}
}
/// A file item returned by `fff_search`.
///
/// All string fields are heap-allocated and owned by the parent `FffSearchResult`.
/// Free the entire result with `fff_free_search_result`.
#[repr(C)]
pub struct FffFileItem {
pub path: *mut c_char,
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
pub is_binary: bool,
}
impl From<&FileItem> for FffFileItem {
fn from(item: &FileItem) -> Self {
FffFileItem {
path: cstring_new(&item.path.to_string_lossy()),
relative_path: cstring_new(&item.relative_path),
file_name: cstring_new(&item.file_name),
git_status: cstring_new(format_git_status(item.git_status)),
size: item.size,
modified: item.modified,
access_frecency_score: item.access_frecency_score,
modification_frecency_score: item.modification_frecency_score,
total_frecency_score: item.total_frecency_score,
is_binary: item.is_binary,
}
}
}
impl FffFileItem {
/// ## Safety
/// All string pointers must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.path.is_null() {
drop(CString::from_raw(self.path));
}
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
}
}
}
/// Score breakdown for a search result.
#[repr(C)]
pub struct FffScore {
pub total: i32,
pub base_score: i32,
pub filename_bonus: i32,
pub special_filename_bonus: i32,
pub frecency_boost: i32,
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub exact_match: bool,
pub match_type: *mut c_char,
}
impl From<&Score> for FffScore {
fn from(score: &Score) -> Self {
FffScore {
total: score.total,
base_score: score.base_score,
filename_bonus: score.filename_bonus,
special_filename_bonus: score.special_filename_bonus,
frecency_boost: score.frecency_boost,
distance_penalty: score.distance_penalty,
current_file_penalty: score.current_file_penalty,
combo_match_boost: score.combo_match_boost,
exact_match: score.exact_match,
match_type: cstring_new(score.match_type),
}
}
}
impl FffScore {
/// ## Safety
/// `match_type` must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.match_type.is_null() {
drop(CString::from_raw(self.match_type));
}
}
}
}
/// Location parsed from a query string (e.g. `"file.ts:42:10"`).
///
/// `tag` encodes the variant:
/// 0 = no location,
/// 1 = line only (`line` is set),
/// 2 = position (`line` + `col`),
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
#[repr(C)]
pub struct FffLocation {
pub tag: u8,
pub line: i32,
pub col: i32,
pub end_line: i32,
pub end_col: i32,
}
impl From<Option<&Location>> for FffLocation {
fn from(loc: Option<&Location>) -> Self {
match loc {
None => FffLocation {
tag: 0,
line: 0,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Line(line)) => FffLocation {
tag: 1,
line: *line,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Position { line, col }) => FffLocation {
tag: 2,
line: *line,
col: *col,
end_line: 0,
end_col: 0,
},
Some(Location::Range { start, end }) => FffLocation {
tag: 3,
line: start.0,
col: start.1,
end_line: end.0,
end_col: end.1,
},
}
}
}
/// Search result returned by `fff_search`.
///
/// The caller must free this with `fff_free_search_result`.
#[repr(C)]
pub struct FffSearchResult {
/// Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
pub items: *mut FffFileItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of files that matched the query.
pub total_matched: u32,
/// Total number of indexed files.
pub total_files: u32,
/// Location parsed from the query string.
pub location: FffLocation,
}
impl FffSearchResult {
/// Convert a core `SearchResult` into a heap-allocated `FffSearchResult`.
pub fn from_core(result: &SearchResult) -> *mut Self {
let items: Vec<FffFileItem> = result.items.iter().map(|i| FffFileItem::from(*i)).collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_files: result.total_files as u32,
location: FffLocation::from(result.location.as_ref()),
}))
}
}
// ---------------------------------------------------------------------------
// Grep result types
// ---------------------------------------------------------------------------
/// A byte range within a matched line, used for highlighting.
#[repr(C)]
pub struct FffMatchRange {
pub start: u32,
pub end: u32,
}
/// A single grep match with file and line information.
///
/// All string fields and arrays are heap-allocated. Free the parent
/// `FffGrepResult` with `fff_free_grep_result` to release everything.
#[repr(C)]
pub struct FffGrepMatch {
// -- pointers (8 bytes each) --
pub path: *mut c_char,
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub line_content: *mut c_char,
pub match_ranges: *mut FffMatchRange,
pub context_before: *mut *mut c_char,
pub context_after: *mut *mut c_char,
// -- 8-byte numeric fields --
pub size: u64,
pub modified: u64,
pub total_frecency_score: i64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub line_number: u64,
pub byte_offset: u64,
// -- 4-byte fields --
pub col: u32,
pub match_ranges_count: u32,
pub context_before_count: u32,
pub context_after_count: u32,
// -- 2-byte fields --
pub fuzzy_score: u16,
// -- 1-byte fields --
pub has_fuzzy_score: bool,
pub is_binary: bool,
pub is_definition: bool,
}
impl FffGrepMatch {
fn from_core_with_file(m: &GrepMatch, file: &FileItem) -> Self {
let ranges: Vec<FffMatchRange> = m
.match_byte_offsets
.iter()
.map(|&(start, end)| FffMatchRange { start, end })
.collect();
let (match_ranges, match_ranges_count) = vec_to_raw(ranges);
let (context_before, context_before_count) = strings_to_raw(&m.context_before);
let (context_after, context_after_count) = strings_to_raw(&m.context_after);
let (has_fuzzy_score, fuzzy_score) = match m.fuzzy_score {
Some(s) => (true, s),
None => (false, 0),
};
FffGrepMatch {
path: cstring_new(&file.path.to_string_lossy()),
relative_path: cstring_new(&file.relative_path),
file_name: cstring_new(&file.file_name),
git_status: cstring_new(format_git_status(file.git_status)),
line_content: cstring_new(&m.line_content),
match_ranges,
context_before,
context_after,
size: file.size,
modified: file.modified,
total_frecency_score: file.total_frecency_score,
access_frecency_score: file.access_frecency_score,
modification_frecency_score: file.modification_frecency_score,
line_number: m.line_number,
byte_offset: m.byte_offset,
col: m.col as u32,
match_ranges_count,
context_before_count,
context_after_count,
fuzzy_score,
has_fuzzy_score,
is_binary: file.is_binary,
is_definition: m.is_definition,
}
}
/// ## Safety
/// All pointers must have been allocated by the corresponding `from_core`.
pub unsafe fn free_fields(&mut self) {
unsafe {
if !self.path.is_null() {
drop(CString::from_raw(self.path));
}
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
if !self.line_content.is_null() {
drop(CString::from_raw(self.line_content));
}
if !self.match_ranges.is_null() {
drop(Vec::from_raw_parts(
self.match_ranges,
self.match_ranges_count as usize,
self.match_ranges_count as usize,
));
}
free_cstring_array(self.context_before, self.context_before_count);
free_cstring_array(self.context_after, self.context_after_count);
}
}
}
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`.
///
/// The caller must free this with `fff_free_grep_result`.
#[repr(C)]
pub struct FffGrepResult {
/// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
pub items: *mut FffGrepMatch,
/// Number of matches in the `items` array.
pub count: u32,
/// Total number of matches (always equal to `count`).
pub total_matched: u32,
/// Number of files actually opened and searched in this call.
pub total_files_searched: u32,
/// Total number of indexed files (before any filtering).
pub total_files: u32,
/// Number of files eligible for search after filtering.
pub filtered_file_count: u32,
/// File offset for the next page. 0 if all files have been searched.
pub next_file_offset: u32,
/// Regex compilation error when falling back to literal matching. Null if none.
pub regex_fallback_error: *mut c_char,
}
impl FffGrepResult {
/// Convert a core `GrepResult` into a heap-allocated `FffGrepResult`.
pub fn from_core(result: &GrepResult) -> *mut Self {
let items: Vec<FffGrepMatch> = result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
FffGrepMatch::from_core_with_file(m, file)
})
.collect();
let (items_ptr, count) = vec_to_raw(items);
Box::into_raw(Box::new(FffGrepResult {
items: items_ptr,
count,
total_matched: result.matches.len() as u32,
total_files_searched: result.total_files_searched as u32,
total_files: result.total_files as u32,
filtered_file_count: result.filtered_file_count as u32,
next_file_offset: result.next_file_offset as u32,
regex_fallback_error: match &result.regex_fallback_error {
Some(e) => cstring_new(e),
None => ptr::null_mut(),
},
}))
}
}
/// Result envelope returned by all `fff_*` functions.
///
/// Heap-allocated — the caller must free it with `fff_free_result`.
///
/// Depending on the function, the payload is delivered through different fields:
///
/// | Function | Payload field | Type |
/// |----------------------------|---------------|-------------------------------|
/// | `fff_create_instance` | `handle` | opaque instance pointer |
/// | `fff_search` | `handle` | `*mut FffSearchResult` |
/// | `fff_live_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_multi_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_get_scan_progress` | `handle` | `*mut FffScanProgress` |
/// | `fff_health_check` | `handle` | `*mut c_char` (JSON string) |
/// | `fff_get_historical_query` | `handle` | `*mut c_char` (string or null)|
/// | `fff_wait_for_scan` | `int_value` | 1 = completed, 0 = timed out |
/// | `fff_track_query` | `int_value` | 1 = success, 0 = failure |
/// | `fff_refresh_git_status` | `int_value` | number of files updated |
/// | `fff_scan_files` | (none) | success flag only |
/// | `fff_restart_index` | (none) | success flag only |
///
/// On failure, `success` is false and `error` contains the message.
///
/// **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
/// The caller must free the handle with the appropriate function
/// (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
/// `fff_free_string`, etc.).
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded.
pub success: bool,
/// Error message on failure. Null on success.
pub error: *mut c_char,
/// Opaque pointer payload (instance handle, typed result struct, or string). May be null.
pub handle: *mut c_void,
/// Integer payload for simple return values (bool as 0/1, counts, etc.).
pub int_value: i64,
}
impl FffResult {
/// Create a successful result with no payload, returned as heap pointer.
pub fn ok_empty() -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
/// Create a successful result with an integer value.
pub fn ok_int(value: i64) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: value,
}))
}
/// Create a successful result carrying an opaque pointer (handle, typed struct, or string).
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle,
int_value: 0,
}))
}
/// Create a successful result carrying a C string in the `handle` field.
/// The caller must free it with `fff_free_string`.
pub fn ok_string(s: &str) -> *mut Self {
let cstr = CString::new(s).unwrap_or_default().into_raw();
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: cstr as *mut c_void,
int_value: 0,
}))
}
/// Create an error result, returned as heap pointer.
pub fn err(error: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: false,
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
}
// ---------------------------------------------------------------------------
// Scan progress
// ---------------------------------------------------------------------------
/// Scan progress returned by `fff_get_scan_progress`.
///
/// The caller must free this with `fff_free_scan_progress`.
#[repr(C)]
pub struct FffScanProgress {
pub scanned_files_count: u64,
pub is_scanning: bool,
}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
[package]
name = "fff-search"
version = "0.2.4"
edition = "2024"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
description = "Faboulous & Fast File Finder - a fast and extremely correct file finder SDK with typo resistance, SIMD, prefiltering, and more"
[lib]
path = "src/lib.rs"
crate-type = ["rlib", "staticlib", "cdylib"]
[features]
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]
ahash = { workspace = true }
rayon = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
fff-query-parser = { workspace = true , version = "0.2.4" }
# External dependencies
bindet = { workspace = true }
blake3 = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
git2 = { workspace = true }
glidesort = { workspace = true }
globset = { workspace = true }
grep-matcher = { workspace = true }
fff-grep = { workspace = true , version = "0.2.4" }
aho-corasick = "1"
memchr = "2"
heed = { workspace = true }
ignore = { workspace = true }
memmap2 = { workspace = true }
neo_frizbee = { workspace = true }
notify = { workspace = true }
notify-debouncer-full = { workspace = true }
once_cell = { workspace = true }
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, optional = true }
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
[target.'cfg(windows)'.dependencies]
dunce = { workspace = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand = { version = "0.8", features = ["small_rng"] }
tempfile = "3.8"
+26
View File
@@ -0,0 +1,26 @@
# fff
fff is a file search toolkit. It is faster than ripgrep and fzf and designed for a long running applications like file editors, ai agents, or file exploerers.
## Features
- Fuzzy file name search
- Typo resistance
- Frecency and query history ranking
- Native git support via libgit
- Advanced ranking
- Grep functionality with SIMD optimized plain matcher and regex
- Multi grep using aho-corasick algorithm
- Efficient memory mapping for file system
- Cross platform support (Linux, Windows, MacOS)
- Advnaced constraints syntax allowing to prefilter based on git status, glob, extension, size, timing and more
## Performance
FFF is designed for high performance and low latency. SIMD optimized where needed, parallelized for multi core systems, efficient sorting and ranking algorithms, memaps and much more.
On MacOS FFF is about 20-50 times faster than ripgrep for content search and around 10 times faster than fzf for file name search.
## Documentation
Refer rust docs https://docs.rs/crate/fff-search/latest
+46
View File
@@ -0,0 +1,46 @@
fn main() {
// When the `zlob` feature is enabled (Zig-compiled C library):
// On Windows MSVC, explicitly link the C runtime libraries.
// 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)
}
+604
View File
@@ -0,0 +1,604 @@
use crate::error::Error;
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, NoCache, new_debouncer_opt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::{Level, debug, error, info, warn};
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 {
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>,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
mode: FFFMode,
) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}, mode: {:?}",
base_path.display(),
mode,
);
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 {
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_for_handler,
&shared_picker,
&shared_frecency,
mode,
);
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
},
// 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,
)?;
// Watch only non-ignored directories to avoid flooding the OS event buffer.
// On macOS, FSEvents has a fixed-size kernel buffer — watching huge gitignored
// directories like `target/` in rust causes buffer overflow, which drops real source file
// events. Instead we watch the root non-recursively (for top-level file changes
// and new directory detection) and each non-ignored subdirectory recursively.
let watch_dirs = collect_non_ignored_dirs(&base_path);
if watch_dirs.len() > MAX_SELECTIVE_WATCH_DIRS {
tracing::warn!(
"Too many non-ignored directories ({}/{}) can't efficiently watch them",
watch_dirs.len(),
MAX_SELECTIVE_WATCH_DIRS
);
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
} else {
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
for dir in &watch_dirs {
match debouncer.watch(dir.as_path(), RecursiveMode::Recursive) {
Ok(()) => {}
Err(e) => {
// Non-fatal: directory may have been removed between discovery and watch
warn!("Failed to watch directory {}: {}", dir.display(), e);
}
}
}
// 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!(
"File watcher initialized for {} directories under {}",
watch_dirs.len(),
base_path.display()
);
Ok(debouncer)
}
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) {
self.stop();
}
}
#[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;
let mut need_full_git_rescan = false;
let mut paths_to_remove = Vec::new();
let mut paths_to_add_or_modify = Vec::new();
let mut affected_paths_count = 0usize;
for debounced_event in &events {
// It is very important to not react to the access errors because we inevitably
// gonna trigger the sync by our own preview or other unnecessary noise
if matches!(
debounced_event.event.kind,
EventKind::Access(
AccessKind::Read
| AccessKind::Open(_)
| AccessKind::Close(AccessMode::Read | AccessMode::Execute)
)
) {
continue;
}
// When macOS FSEvents (or other backends) overflow their event buffer, the kernel
// drops individual events and emits a Rescan flag telling us to re-scan the subtree.
// Without handling this, modified source files can be silently missed.
if debounced_event.event.need_rescan() {
warn!(
"Received rescan event for paths {:?}, triggering full rescan",
debounced_event.event.paths
);
need_full_rescan = true;
break;
}
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
for path in &debounced_event.event.paths {
if is_ignore_definition_path(path) {
info!(
"Detected change in ignore definition file: {}",
path.display()
);
need_full_rescan = true;
break;
}
if is_dotgit_change_affecting_status(path, &repo) {
need_full_git_rescan = true;
}
if is_git_file(path) {
continue;
}
// 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 {
// For additions/modifications, still filter gitignored files.
if should_include_file(path, &repo) {
paths_to_add_or_modify.push(path.as_path());
}
}
}
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_PATHS_THRESHOLD {
warn!(
"Too many affected paths ({}) in a single batch, triggering full rescan",
affected_paths_count
);
need_full_rescan = true;
break;
}
if need_full_rescan {
break;
}
}
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
trigger_full_rescan(shared_picker, shared_frecency);
return;
}
// It's important to get the allocated sort
sort_with_buffer(paths_to_add_or_modify.as_mut_slice(), |a, b| {
a.as_os_str().cmp(b.as_os_str())
});
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
info!(
"Event processing summary: {} to remove, {} to add/modify",
paths_to_remove.len(),
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 {
debug!("No git repo available, skipping git status updates");
return;
};
if need_full_git_rescan {
info!("Triggering full git rescan");
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
if let Err(e) = result {
error!("Failed to refresh git status: {:?}", e);
}
return;
}
if !files_to_update_git_status.is_empty() {
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 status");
return;
}
};
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 {
error!("Failed to acquire picker lock for git status update");
}
}
}
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 guard) = shared_picker.write() else {
error!("Failed to acquire file picker write lock for full rescan");
return;
};
let Some(ref mut picker) = *guard else {
error!("File picker not initialized, cannot trigger rescan");
return;
};
if let Err(e) = picker.trigger_rescan(shared_frecency) {
error!("Failed to trigger full rescan: {:?}", e);
} else {
info!("Full filesystem rescan completed successfully");
}
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
// Directories are not indexed — only regular files (and symlinks to files).
if path.is_dir() {
return 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]
fn is_git_file(path: &Path) -> bool {
path.components()
.any(|component| component.as_os_str() == ".git")
}
pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
let Some(repo) = repo.as_ref() else {
return false;
};
let git_dir = repo.path();
if let Ok(rel) = changed.strip_prefix(git_dir) {
if rel.starts_with("objects") || rel.starts_with("logs") || rel.starts_with("hooks") {
return false;
}
if rel == Path::new("index") || rel == Path::new("index.lock") {
return true;
}
if rel == Path::new("HEAD") {
return true;
}
if rel.starts_with("refs") || rel == Path::new("packed-refs") {
return true;
}
if rel == Path::new("info/exclude") || rel == Path::new("info/sparse-checkout") {
return true;
}
if let Some(fname) = rel.file_name().and_then(|f| f.to_str())
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
{
return true;
}
}
false
}
fn is_ignore_definition_path(path: &Path) -> bool {
matches!(
path.file_name().and_then(|f| f.to_str()),
Some(".ignore") | Some(".gitignore")
)
}
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,
/// preventing gitignored directories like `target/` from flooding the OS event buffer.
fn collect_non_ignored_dirs(base_path: &Path) -> Vec<PathBuf> {
use ignore::WalkBuilder;
let walker = WalkBuilder::new(base_path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.ignore(true)
.follow_links(false)
.max_depth(Some(1))
.build();
let mut dirs = Vec::new();
for entry in walker {
let Ok(entry) = entry else { continue };
let path = entry.path();
// Skip the root directory itself
if path == base_path {
continue;
}
if path.is_dir() && !is_git_file(path) {
dirs.push(path.to_path_buf());
}
}
dirs
}
+449
View File
@@ -0,0 +1,449 @@
//! Constraint filtering engine for fff.
//!
//! This module provides the core constraint application logic that filters items
//! based on parsed query constraints (extensions, path segments, globs, git status, etc.).
//!
//! The filtering is generic over the [`Constrainable`] trait, allowing reuse across
//! different search modes (file picker, live grep, etc.).
use ahash::AHashSet;
use fff_query_parser::{Constraint, GitStatusFilter};
use smallvec::SmallVec;
use crate::git::is_modified_status;
/// Minimum item count before switching to parallel iteration with rayon.
/// Below this threshold, the overhead of thread pool dispatch outweighs the benefit.
const PAR_THRESHOLD: usize = 10_000;
/// Trait for items that can be filtered by constraints.
/// Implement this for any searchable item type (files, grep results, etc.).
pub trait Constrainable {
/// The file's relative path (e.g. "src/main.rs")
fn relative_path(&self) -> &str;
/// The file's lowercased relative path for case-insensitive matching
fn relative_path_lower(&self) -> &str;
/// The file name component (e.g. "main.rs")
fn file_name(&self) -> &str;
/// The git status of this item, if available
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 {
if file_name.len() <= ext.len() + 1 {
return false;
}
let start = file_name.len() - ext.len() - 1;
file_name.as_bytes().get(start) == Some(&b'.')
&& file_name[start + 1..].eq_ignore_ascii_case(ext)
}
/// 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 of path
if path.len() > segment_len
&& path_bytes.get(segment_len) == Some(&b'/')
&& path[..segment_len].eq_ignore_ascii_case(segment)
{
return true;
}
// Check /segment/ anywhere using byte scanning
if path.len() < segment_len + 2 {
return false;
}
for i in 0..path.len().saturating_sub(segment_len + 1) {
if path_bytes[i] == b'/' {
let start = i + 1;
let end = start + segment_len;
if end < path.len()
&& path_bytes[end] == b'/'
&& path[start..end].eq_ignore_ascii_case(segment)
{
return true;
}
}
}
false
}
/// Check if an item at given index matches a constraint (single-pass friendly, allocation-free)
#[inline]
fn item_matches_constraint_at_index<T: Constrainable>(
item: &T,
item_index: usize,
constraint: &Constraint<'_>,
glob_results: &[(bool, AHashSet<usize>)],
glob_idx: &mut usize,
negate: bool,
) -> bool {
let matches = match constraint {
Constraint::Extension(ext) => file_has_extension(item.file_name(), ext),
Constraint::Glob(_) => {
let result = glob_results
.get(*glob_idx)
.map(|(is_neg, set)| {
let matched = set.contains(&item_index);
if *is_neg { !matched } else { matched }
})
.unwrap_or(true);
*glob_idx += 1;
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),
(Some(status), GitStatusFilter::Staged) => status.intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE,
),
(Some(status), GitStatusFilter::Unmodified) => status.is_empty(),
(None, GitStatusFilter::Unmodified) => true,
(None, _) => false,
},
Constraint::Not(inner) => {
return item_matches_constraint_at_index(
item,
item_index,
inner,
glob_results,
glob_idx,
!negate,
);
}
// only works with negation
Constraint::Text(text) => item.relative_path_lower().contains(text),
// Parts and Exclude are handled at a higher level
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
};
if negate { !matches } else { matches }
}
/// Apply constraint-based prefiltering in a single pass over all items.
/// Returns `None` if no constraints are present, `Some(filtered)` otherwise.
/// Multiple extension constraints (*.rs *.ts) are combined with OR logic.
/// All other constraints are combined with AND logic.
///
/// Uses parallel iteration via rayon when the item count exceeds [`PAR_THRESHOLD`].
pub fn apply_constraints<'a, T: Constrainable + Sync>(
items: &'a [T],
constraints: &[Constraint<'_>],
) -> Option<Vec<&'a T>> {
if constraints.is_empty() {
return None;
}
// Separate extension constraints from other constraints — they use OR logic
let mut extensions: SmallVec<[&str; 8]> = SmallVec::new();
let mut other_constraints: SmallVec<[&Constraint<'_>; 8]> = SmallVec::new();
for constraint in constraints {
match constraint {
Constraint::Extension(ext) => extensions.push(ext),
_ => other_constraints.push(constraint),
}
}
// Only collect paths if we have glob constraints (expensive)
let has_globs = other_constraints
.iter()
.any(|c| matches!(c, Constraint::Glob(_) | Constraint::Not(_)));
let glob_results = if has_globs {
let paths: Vec<&str> = items.iter().map(|f| f.relative_path()).collect();
precompute_glob_matches(&other_constraints, &paths)
} else {
Vec::new()
};
let matches_constraints = |i: usize, item: &T| -> bool {
if !extensions.is_empty()
&& !extensions
.iter()
.any(|ext| file_has_extension(item.file_name(), ext))
{
return false;
}
let mut glob_idx = 0;
other_constraints.iter().all(|constraint| {
item_matches_constraint_at_index(
item,
i,
constraint,
&glob_results,
&mut glob_idx,
false,
)
})
};
let filtered: Vec<&T> = if items.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
items
.par_iter()
.enumerate()
.filter(|(i, item)| matches_constraints(*i, item))
.map(|(_, item)| item)
.collect()
} else {
items
.iter()
.enumerate()
.filter(|(i, item)| matches_constraints(*i, item))
.map(|(_, item)| item)
.collect()
};
Some(filtered)
}
fn precompute_glob_matches<'a>(
constraints: &[&Constraint<'a>],
paths: &[&str],
) -> Vec<(bool, AHashSet<usize>)> {
let mut results = Vec::new();
for constraint in constraints {
collect_glob_indices(constraint, paths, &mut results, false);
}
results
}
fn collect_glob_indices<'a>(
constraint: &Constraint<'a>,
paths: &[&str],
results: &mut Vec<(bool, AHashSet<usize>)>,
is_negated: bool,
) {
match constraint {
Constraint::Glob(pattern) => {
let indices = match_glob_pattern(pattern, paths);
results.push((is_negated, indices));
}
Constraint::Not(inner) => {
collect_glob_indices(inner, paths, results, !is_negated);
}
_ => {}
}
}
/// 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::*;
#[test]
fn test_file_has_extension() {
assert!(file_has_extension("file.rs", "rs"));
assert!(file_has_extension("file.RS", "rs")); // case-insensitive
assert!(file_has_extension("file.test.rs", "rs"));
assert!(file_has_extension("a.rs", "rs"));
assert!(!file_has_extension("file.tsx", "rs"));
assert!(!file_has_extension("rs", "rs")); // too short
assert!(!file_has_extension(".rs", "rs")); // just extension
assert!(!file_has_extension("file.rsx", "rs")); // different extension
assert!(!file_has_extension("filers", "rs")); // no dot
}
#[test]
fn test_path_contains_segment() {
// Segment at start
assert!(path_contains_segment("src/lib.rs", "src"));
assert!(path_contains_segment("SRC/lib.rs", "src")); // case-insensitive
// Segment in middle
assert!(path_contains_segment("app/src/lib.rs", "src"));
assert!(path_contains_segment("app/SRC/lib.rs", "src"));
// Multiple levels
assert!(path_contains_segment("core/workflow/src/main.rs", "src"));
assert!(path_contains_segment(
"core/workflow/src/main.rs",
"workflow"
));
assert!(path_contains_segment("core/workflow/src/main.rs", "core"));
// Should not match partial segments
assert!(!path_contains_segment("source/lib.rs", "src"));
assert!(!path_contains_segment("mysrc/lib.rs", "src"));
// 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"));
}
}
+31
View File
@@ -0,0 +1,31 @@
use crate::error::Result;
/// Health information about a database
#[derive(Debug, Clone)]
pub struct DbHealth {
/// Path to the database file
pub path: String,
/// Size on disk in bytes
pub disk_size: u64,
/// Entry counts by table name
pub entry_counts: Vec<(&'static str, u64)>,
}
pub trait DbHealthChecker {
fn get_env(&self) -> &heed::Env;
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>>;
fn get_health(&self) -> Result<DbHealth> {
let env = self.get_env();
let size = env.real_disk_size().map_err(crate::error::Error::EnvOpen)?;
let path = env.path().to_string_lossy().to_string();
let entry_counts = self.count_entries()?;
Ok(DbHealth {
path,
disk_size: size,
entry_counts,
})
}
}
@@ -13,6 +13,8 @@ pub enum Error {
AcquireFrecencyLock,
#[error("Failed to acquire lock for items by provider")]
AcquireItemLock,
#[error("Failed to acquire lock for path cache")]
AcquirePathCacheLock,
#[error("Failed to create directory: {0}")]
CreateDir(#[from] std::io::Error),
#[error("Failed to open frecency database env: {0}")]
@@ -43,13 +45,4 @@ pub enum Error {
Git(#[from] git2::Error),
}
impl From<Error> for mlua::Error {
fn from(value: Error) -> Self {
let string_value = value.to_string();
::tracing::error!(string_value);
mlua::Error::RuntimeError(string_value)
}
}
pub type Result<T> = std::result::Result<T, Error>;
File diff suppressed because it is too large Load Diff
+553
View File
@@ -0,0 +1,553 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::error::{Error, Result};
use crate::file_picker::FFFMode;
use crate::{SharedFrecency, 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};
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,
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
}
const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(16, 60 * 2), // 2 minutes
(8, 60 * 15), // 15 minutes
(4, 60 * 60), // 1 hour
(2, 60 * 60 * 24), // 1 day
(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
}
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let count = self.db.len(&rtxn).map_err(Error::DbRead)?;
Ok(vec![("absolute_frecency_entries", count)])
}
}
impl FrecencyTracker {
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self> {
let db_path = db_path.as_ref();
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);
}
opts.open(db_path).map_err(Error::EnvOpen)?
};
env.clear_stale_readers()
.map_err(Error::DbClearStaleReaders)?;
// we will open the default unnamed database
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
let db = env
.create_database(&mut wtxn, None)
.map_err(Error::DbCreate)?;
Ok(FrecencyTracker {
db,
env: env.clone(),
})
}
/// Spawns a background thread to purge stale frecency entries and compact the database.
/// Run it once in a while to purge old pages and keep DB file size reasonable.
///
/// It's okay to not join this thread since it acquires locks for the db access
///
/// ```
/// use fff_search::frecency::FrecencyTracker;
/// use fff_search::SharedFrecency;
/// let shared_frecency: SharedFrecency = Default::default();
/// let _ = FrecencyTracker::spawn_gc(shared_frecency, "/path/to/frecency_db".into(), true).ok();
/// ```
pub fn spawn_gc(
shared: SharedFrecency,
db_path: String,
use_unsafe_no_lock: bool,
) -> Result<std::thread::JoinHandle<()>> {
Ok(std::thread::Builder::new()
.name("fff-frecency-gc".into())
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))?)
}
#[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)> {
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>>> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let key_hash = Self::path_to_hash_bytes(path)?;
self.db.get(&rtxn, &key_hash).map_err(Error::DbRead)
}
fn get_now(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32]> {
let Some(key) = path.to_str() else {
return Err(Error::InvalidPath(path.to_path_buf()));
};
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>> {
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<()> {
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
let key_hash = Self::path_to_hash_bytes(path)?;
let mut accesses = self.get_accesses(path)?.unwrap_or_default();
let now = self.get_now();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
while let Some(&front_time) = accesses.front() {
if front_time < cutoff_time {
accesses.pop_front();
} else {
break;
}
}
accesses.push_back(now);
tracing::debug!(?path, accesses = accesses.len(), "Tracking access");
self.db
.put(&mut wtxn, &key_hash, &accesses)
.map_err(Error::DbWrite)?;
wtxn.commit().map_err(Error::DbCommit)?;
Ok(())
}
pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
let accesses = self
.get_accesses(file_path)
.ok()
.flatten()
.unwrap_or_default();
if accesses.is_empty() {
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);
for &access_time in accesses.iter().rev() {
if access_time < cutoff_time {
break; // All remaining entries are older, stop processing
}
let days_ago = (now.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
let decay_factor = (-decay_constant * days_ago).exp();
total_frecency += decay_factor;
}
let normalized_frecency = if total_frecency <= 10.0 {
total_frecency
} else {
10.0 + (total_frecency - 10.0).sqrt() // Diminishing: >10 accesses grow slowly
};
normalized_frecency.round() as i64
}
/// Calculating modification score but only if the file is modified in the current git dir
pub fn get_modification_score(
&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..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) = thresholds[i - 1];
let time_range = current_threshold - prev_threshold;
let time_offset = duration_since - prev_threshold;
let points_diff = prev_points - current_points;
let interpolated_score =
prev_points - (points_diff * time_offset as i64) / time_range as i64;
return interpolated_score;
}
}
0
}
}
#[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;
for &access_time in access_timestamps {
let days_ago = (current_time.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
let decay_factor = (-DECAY_CONSTANT * days_ago).exp();
total_frecency += decay_factor;
}
let normalized_frecency = if total_frecency <= 20.0 {
total_frecency
} else {
20.0 + (total_frecency - 10.0).sqrt()
};
normalized_frecency.round() as i64
}
#[test]
fn test_frecency_calculation() {
let current_time = 1000000000; // Base timestamp
let score = calculate_test_frecency_score(&[], current_time);
assert_eq!(score, 0);
let accesses = [current_time]; // Accessed right now
let score = calculate_test_frecency_score(&accesses, current_time);
assert_eq!(score, 1); // 1.0 decay factor = 1
let ten_days_seconds = 10 * 86400; // 10 days in seconds
let accesses = [current_time - ten_days_seconds];
let score = calculate_test_frecency_score(&accesses, current_time);
assert_eq!(score, 1); // ~0.5 decay factor rounds to 1
let accesses = [
current_time, // Today
current_time - 86400, // 1 day ago
current_time - 172800, // 2 days ago
];
let score = calculate_test_frecency_score(&accesses, current_time);
assert!(score > 2 && score < 4, "Score: {}", score); // About 3 accesses with decay
let thirty_days = 30 * 86400;
let accesses = [current_time - thirty_days]; // 30 days ago
let score = calculate_test_frecency_score(&accesses, current_time);
assert!(
score < 2,
"Old access should have minimal score, got: {}",
score
);
let recent_frequent = [current_time, current_time - 86400, current_time - 172800];
let old_single = [current_time - ten_days_seconds];
let recent_score = calculate_test_frecency_score(&recent_frequent, current_time);
let old_score = calculate_test_frecency_score(&old_single, current_time);
assert!(
recent_score > old_score,
"Recent frequent access ({}) should score higher than old single access ({})",
recent_score,
old_score
);
}
#[test]
fn test_modification_score_interpolation() {
let temp_dir = std::env::temp_dir().join("fff_test_interpolation");
let _ = std::fs::remove_dir_all(&temp_dir);
let tracker = FrecencyTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
let current_time = tracker.get_now();
let git_status = Some(git2::Status::WT_MODIFIED);
// 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, 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, 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, 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, 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, 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, FFFMode::Neovim);
assert_eq!(score, 0, "No git status should return 0");
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
@@ -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")
}
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
//! # FFF Search — High-performance file finder core
//!
//! This crate provides the core search engine for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim).
//! It includes filesystem indexing with real-time watching, fuzzy matching powered
//! by [frizbee](https://docs.rs/neo_frizbee), frecency scoring backed by LMDB,
//! and multi-mode grep search.
//!
//! ## Architecture
//!
//! - [`file_picker::FilePicker`] — Main entry point. Indexes a directory tree in a
//! background thread, maintains a sorted file list, watches the filesystem for
//! changes, and performs fuzzy search with frecency-weighted scoring.
//! - [`frecency::FrecencyTracker`] — LMDB-backed database that tracks file access
//! and modification patterns for intelligent result ranking.
//! - [`query_tracker::QueryTracker`] — Tracks search query history and provides
//! "combo-boost" scoring for repeatedly matched files.
//! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes
//! with optional constraint filtering.
//! - [`git`] — Git status caching and repository detection.
//!
//! ## Shared State
//!
//! [`SharedPicker`], [`SharedFrecency`], and [`SharedQueryTracker`] are
//! `Arc<RwLock<Option<T>>>` type aliases for thread-safe shared access. FFF
//! is designed for long-running processes that keep the file index in global
//! state, so these wrappers let background threads (scanner, watcher) share
//! data with the calling code safely.
//!
//! ## Quick Start
//!
//! ```
//! use fff_search::file_picker::FilePicker;
//! use fff_search::frecency::FrecencyTracker;
//! use fff_search::query_tracker::QueryTracker;
//! use fff_search::{
//! FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser,
//! SharedFrecency, SharedPicker, SharedQueryTracker,
//! };
//!
//! let shared_picker: SharedPicker = Default::default();
//! let shared_frecency: SharedFrecency = Default::default();
//! let shared_query_tracker: SharedQueryTracker = Default::default();
//!
//! let tmp = std::env::temp_dir().join("fff-doctest");
//! std::fs::create_dir_all(&tmp).unwrap();
//!
//! // 1. Optionally initialize frecency and query tracker databases
//! let frecency = FrecencyTracker::new(tmp.join("frecency"), false)?;
//! *shared_frecency.write().unwrap() = Some(frecency);
//!
//! let query_tracker = QueryTracker::new(tmp.join("queries"), false)?;
//! *shared_query_tracker.write().unwrap() = Some(query_tracker);
//!
//! // 2. Init the file picker (spawns background scan + watcher)
//! FilePicker::new_with_shared_state(
//! ".".into(),
//! /* warmup memap caches = */ false,
//! FFFMode::Ai, // use AI for ai agents, and Neovim for editors
//! shared_picker.clone(),
//! shared_frecency.clone(),
//! )?;
//!
//! // 3. Wait for scan (in real app you would like to add some tokio flavor here)
//! FilePicker::wait_for_scan(&shared_picker);
//!
//! // 4. Search: lock the picker and query tracker
//! let picker_lock_guard = shared_picker.read().unwrap();
//! let picker = picker_lock_guard.as_ref().unwrap();
//! let query_tracker_lock_guard = shared_query_tracker.read().unwrap();
//!
//! // 5. Parse the query and perform fuzzy search with frecency and combo-boost scoring
//! let parser = QueryParser::default();
//! let query = parser.parse("lib.rs");
//!
//! let results = FilePicker::fuzzy_search(
//! picker.get_files(),
//! &query,
//! query_tracker_lock_guard.as_ref(),
//! FuzzySearchOptions {
//! max_threads: 0,
//! current_file: None,
//! pagination: PaginationArgs { offset: 0, limit: 50 },
//! ..Default::default()
//! },
//! );
//!
//! assert!(results.total_matched > 0);
//! assert!(results.items.first().unwrap().path.ends_with("lib.rs"));
//!
//! let _ = std::fs::remove_dir_all(&tmp);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
mod background_watcher;
mod constraints;
mod db_healthcheck;
mod error;
mod score;
mod sort_buffer;
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
///
/// See [`FilePicker`](file_picker::FilePicker) for the main entry point.
pub mod file_picker;
/// Frecency (frequency + recency) database for file access scoring.
///
/// Backed by LMDB for persistent, crash-safe storage.
pub mod frecency;
/// Git status caching and repository detection utilities.
pub mod git;
/// Live grep search with regex, plain-text, and fuzzy matching modes.
///
/// Supports constraint filtering (file extensions, path segments, globs)
/// and parallel execution via rayon.
pub mod grep;
/// Tracing/logging initialization and panic hook setup.
pub mod log;
/// Path manipulation utilities: cross platform canonicalization, tilde expansion, and
/// directory distance penalties for search scoring.
pub mod path_utils;
/// Search query history tracker for combo-boost scoring.
///
/// Records which files a user selects for each query, enabling the scorer
/// to boost files that were previously chosen for similar searches.
pub mod query_tracker;
/// Core data types shared across the crate.
pub mod types;
use std::sync::{Arc, RwLock};
/// Thread-safe shared handle to the [`FilePicker`] instance.
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
/// Thread-safe shared handle to the [`QueryTracker`] instance.
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
pub use db_healthcheck::{DbHealth, DbHealthChecker};
pub use error::{Error, Result};
pub use fff_query_parser::*;
pub use file_picker::*;
pub use frecency::*;
pub use grep::*;
pub use query_tracker::*;
pub use types::*;
@@ -1,4 +1,9 @@
use crate::error::Error;
//! 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;
@@ -7,15 +12,108 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
/// Initialize tracing with single log file
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, Error>` - Full path to the log file on success
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, Error> {
/// * `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)?;
@@ -27,18 +125,7 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
.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,
};
let level = parse_log_level(log_level);
TRACING_INITIALIZED.get_or_init(|| {
let (non_blocking_appender, guard) = non_blocking(file_appender);
@@ -65,41 +152,11 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF.nvim tracing initialized with log file: {}",
"FFF tracing initialized with log file: {}",
log_path.display()
);
}
std::panic::set_hook(Box::new(|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()
};
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF.nvim"
);
eprintln!("FFF.nvim PANIC: {} at {}", message, location);
}));
guard
});
@@ -1,3 +1,33 @@
use std::path::{Path, PathBuf};
#[cfg(windows)]
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
dunce::canonicalize(path)
}
#[cfg(not(windows))]
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 {
let Some(ref current_path) = current_file else {
return 0; // No penalty if no current file
@@ -1,3 +1,4 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::error::Error;
use heed::types::Bytes;
use heed::{Database, Env, EnvOpenOptions};
@@ -30,15 +31,43 @@ pub struct QueryTracker {
env: Env,
// Database for (project_path, query) -> QueryMatchEntry mappings
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings
// Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings (grep)
grep_query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
}
impl DbHealthChecker for QueryTracker {
fn get_env(&self) -> &Env {
&self.env
}
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let count_queries = self.query_file_db.len(&rtxn).map_err(Error::DbRead)?;
let count_histories = self.query_history_db.len(&rtxn).map_err(Error::DbRead)?;
let count_grep_histories = self
.grep_query_history_db
.len(&rtxn)
.map_err(Error::DbRead)?;
Ok(vec![
("query_file_entries", count_queries),
("query_history_entries", count_histories),
("grep_query_history_entries", count_grep_histories),
])
}
}
impl QueryTracker {
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self, Error> {
let db_path = db_path.as_ref();
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);
@@ -58,6 +87,9 @@ impl QueryTracker {
let query_history_db = env
.create_database(&mut wtxn, Some("query_history"))
.map_err(Error::DbCreate)?;
let grep_query_history_db = env
.create_database(&mut wtxn, Some("grep_query_history"))
.map_err(Error::DbCreate)?;
wtxn.commit().map_err(Error::DbCommit)?;
@@ -65,6 +97,7 @@ impl QueryTracker {
env,
query_file_db,
query_history_db,
grep_query_history_db,
})
}
@@ -96,6 +129,57 @@ impl QueryTracker {
Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
}
/// Append a query to a history database within an existing write transaction.
fn append_to_history(
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
wtxn: &mut heed::RwTxn,
project_key: &[u8; 32],
query: &str,
now: u64,
) -> Result<(), Error> {
let mut history = db
.get(wtxn, project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
history.push_back(HistoryEntry {
query: query.to_string(),
timestamp: now,
});
while history.len() > MAX_HISTORY_ENTRIES {
history.pop_front();
}
db.put(wtxn, project_key, &history)
.map_err(Error::DbWrite)?;
Ok(())
}
/// Read a query from a history database at a specific offset.
/// offset=0 returns most recent, offset=1 returns 2nd most recent, etc.
fn read_history_at_offset(
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
env: &Env,
project_key: &[u8; 32],
offset: usize,
) -> Result<Option<String>, Error> {
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
let mut history = db
.get(&rtxn, project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
// history is FIFO, last element is most recent
if history.len() > offset {
let index = history.len() - 1 - offset;
let record = history.remove(index);
Ok(record.map(|r| r.query))
} else {
Ok(None)
}
}
pub fn track_query_completion(
&mut self,
query: &str,
@@ -147,24 +231,7 @@ impl QueryTracker {
// Update query history database
let project_key = Self::create_project_key(project_path)?;
let mut history = self
.query_history_db
.get(&wtxn, &project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
let history_entry = HistoryEntry {
query: query.to_string(),
timestamp: now,
};
history.push_back(history_entry);
while history.len() > MAX_HISTORY_ENTRIES {
history.pop_front();
}
self.query_history_db
.put(&mut wtxn, &project_key, &history)
.map_err(Error::DbWrite)?;
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)?;
wtxn.commit().map_err(Error::DbCommit)?;
@@ -218,32 +285,47 @@ impl QueryTracker {
}
}
/// Get query from history at a specific offset
/// Get query from file picker history at a specific offset.
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
/// Returns None if offset exceeds history length
pub fn get_historical_query(
&self,
project_path: &Path,
offset: usize,
) -> Result<Option<String>, Error> {
let project_key = Self::create_project_key(project_path)?;
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
Self::read_history_at_offset(&self.query_history_db, &self.env, &project_key, offset)
}
let mut history = self
.query_history_db
.get(&rtxn, &project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
/// Track a grep query in the grep-specific history.
/// Only records query history (no file association tracking needed for grep).
pub fn track_grep_query(&mut self, query: &str, project_path: &Path) -> Result<(), Error> {
let now = self.get_now();
let project_key = Self::create_project_key(project_path)?;
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
// history is FIFO, last element is most recent
if history.len() > offset {
let index = history.len() - 1 - offset;
let record = history.remove(index);
Self::append_to_history(
&self.grep_query_history_db,
&mut wtxn,
&project_key,
query,
now,
)?;
Ok(record.map(|r| r.query))
} else {
Ok(None)
}
wtxn.commit().map_err(Error::DbCommit)?;
tracing::debug!(?query, "Tracked grep query");
Ok(())
}
/// Get grep query from history at a specific offset.
/// offset=0 returns most recent grep query, offset=1 returns 2nd most recent, etc.
pub fn get_historical_grep_query(
&self,
project_path: &Path,
offset: usize,
) -> Result<Option<String>, Error> {
let project_key = Self::create_project_key(project_path)?;
Self::read_history_at_offset(&self.grep_query_history_db, &self.env, &project_key, offset)
}
}
+742
View File
@@ -0,0 +1,742 @@
use crate::{
constraints::apply_constraints,
git::is_modified_status,
path_utils::calculate_distance_penalty,
sort_buffer::{sort_by_key_with_buffer, sort_with_buffer},
types::{FileItem, Score, ScoringContext},
};
use fff_query_parser::FuzzyQuery;
use neo_frizbee::Scoring;
use rayon::prelude::*;
use std::path::MAIN_SEPARATOR;
// like cow but better
pub(crate) enum FileItems<'a> {
/// All files — borrows the original owned slice, zero allocation.
All(&'a [FileItem]),
/// Filtered subset — owns references produced by constraint filtering.
Filtered(Vec<&'a FileItem>),
}
impl<'a> FileItems<'a> {
#[inline]
#[allow(dead_code)]
fn len(&self) -> usize {
match self {
FileItems::All(s) => s.len(),
FileItems::Filtered(v) => v.len(),
}
}
#[inline]
#[allow(dead_code)]
fn is_empty(&self) -> bool {
self.len() == 0
}
#[inline]
fn get(&self, index: usize) -> Option<&'a FileItem> {
match self {
FileItems::All(s) => s.get(index),
FileItems::Filtered(v) => v.get(index).copied(),
}
}
/// Build the haystack of relative paths (original casing) for fuzzy matching.
/// neo_frizbee lowercases internally for comparison but preserves original casing
/// for capitalization_bonus and matching_case_bonus scoring.
fn relative_paths(&self) -> Vec<&'a str> {
match self {
FileItems::All(s) => s.iter().map(|f| f.relative_path.as_str()).collect(),
FileItems::Filtered(v) => v.iter().map(|f| f.relative_path.as_str()).collect(),
}
}
/// Index into the file list. Panics if out of bounds (like slice indexing).
#[inline]
fn index(&self, index: usize) -> &'a FileItem {
match self {
FileItems::All(s) => &s[index],
FileItems::Filtered(v) => v[index],
}
}
}
/// Match files against all fuzzy parts.
/// Single part: use optimized batch matching.
/// Multiple parts: each part must match, scores are summed (Nucleo-style).
/// Parts with less than 2 characters are skipped.
fn match_fuzzy_parts(
fuzzy_parts: &[&str],
working_files: &FileItems<'_>,
options: &neo_frizbee::Config,
) -> Vec<neo_frizbee::Match> {
if fuzzy_parts.is_empty() {
return vec![];
}
let haystack: Vec<&str> = working_files.relative_paths();
// Filter out parts that are too short (< 2 chars)
let valid_parts: Vec<&str> = fuzzy_parts
.iter()
.copied()
.filter(|p| p.len() >= 2)
.collect();
if valid_parts.is_empty() {
tracing::debug!("match_fuzzy_parts: no valid parts after filtering, returning empty");
return vec![];
}
if valid_parts.len() == 1 {
let matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
return matches;
}
// Multiple parts - match first part, then filter by remaining parts
// TODO figure out if we can move this logic to my frizbee fork at least
let mut matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
for part in valid_parts[1..].iter() {
let mut part_options = *options;
part_options.max_typos = options.max_typos.map(|t| t.min(part.len() as u16));
matches = matches
.into_iter()
.filter_map(|mut m| {
let path = haystack.get(m.index as usize)?;
let part_matches = neo_frizbee::match_list(part, &[*path], &part_options);
let part_match = part_matches.first()?;
// Sum scores
let total = (m.score as u32).saturating_add(part_match.score as u32);
m.score = total.min(u16::MAX as u32) as u16;
Some(m)
})
.collect();
if matches.is_empty() {
break;
}
}
matches
}
pub fn match_and_score_files<'a>(
files: &'a [FileItem],
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
if files.is_empty() {
return (vec![], vec![], 0);
}
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),
}
};
let query_trimmed: &str = parsed.raw_query.trim();
let single_part_storage: [&str; 1] = [query_trimmed];
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
}
};
let has_uppercase = fuzzy_parts
.iter()
.any(|p| p.chars().any(|c| c.is_uppercase()));
let query_contains_path_separator = fuzzy_parts.iter().any(|p| p.contains(MAIN_SEPARATOR));
let options = neo_frizbee::Config {
max_typos: Some(context.max_typos),
sort: false,
scoring: Scoring {
capitalization_bonus: if has_uppercase { 8 } else { 0 },
matching_case_bonus: if has_uppercase { 4 } else { 0 },
..Default::default()
},
};
let path_matches = match_fuzzy_parts(fuzzy_parts, &working_files, &options);
let primary_text = fuzzy_parts[0]; // Use first part for filename matching
let haystack_of_filenames: Vec<&str> = path_matches
.iter()
.filter_map(|m| {
working_files
.get(m.index as usize)
.map(|f| f.file_name.as_str())
})
.collect();
// if there is a / in the query we don't even match filenames
let filename_matches = if query_contains_path_separator {
vec![]
} else {
// Use parallel matching only if we have enough filenames to justify overhead
// Sequential matching is faster for small result sets (< 1000 matches)
let mut list = if haystack_of_filenames.len() > 1000 {
neo_frizbee::match_list_parallel(
primary_text,
&haystack_of_filenames,
&options,
context.max_threads,
)
} else {
neo_frizbee::match_list(primary_text, &haystack_of_filenames, &options)
};
// Sequential sort is faster for small lists
if list.len() > 1000 {
list.par_sort_unstable_by_key(|m| m.index);
} else {
sort_by_key_with_buffer(&mut list, |m| m.index);
}
list
};
let mut next_filename_match_index = 0;
let results: Vec<_> = path_matches
.into_iter()
.enumerate()
.map(|(index, path_match)| {
let file_idx = path_match.index as usize;
let file = working_files.index(file_idx);
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);
let filename_match = filename_matches
.get(next_filename_match_index)
.and_then(|m| {
if m.index == index as u32 {
next_filename_match_index += 1;
Some(m)
} else {
None
}
});
let mut has_special_filename_bonus = false;
let filename_bonus = match filename_match {
Some(filename_match) if filename_match.exact => {
filename_match.score as i32 / 5 * 2 // 40% bonus for exact filename match
}
// 16% bonus for fuzzy filename match but only if the score of matched path is
// equal or greater than the score of matched filename, thus we are not allowing
// typoed filename to score higher than the path match
Some(filename_match)
if filename_match.score >= path_match.score
&& !query_contains_path_separator =>
{
base_score = filename_match.score as i32;
(base_score / 6)
// for large queries around ~300 score the bonus is too big
// it might lead to situations when much more fitting path with a larger
// base score getting filtered out by combination of score + filename bonus
// so we cap it at 10% of the roughly largest score you can get
.min(30)
}
// 5% bonus for special file but not as much as file name to avoid sitatuions
// when you have /user_service/server.rs and /user_service/server/mod.rs
None if is_special_entry_point_file(&file.file_name) => {
has_special_filename_bonus = true;
base_score * 5 / 100
}
_ => 0,
};
let current_file_penalty = calculate_current_file_penalty(file, base_score, context);
let combo_match_boost = {
let last_same_query_match = context
.last_same_query_match
.as_ref()
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
match last_same_query_match {
// if we request a combo match without a boost we have to render it anyway
Some(_) if context.min_combo_count == 0 => 1000,
Some(combo_match) if combo_match.open_count >= context.min_combo_count => {
combo_match.open_count as i32 * context.combo_boost_score_multiplier
}
// until we hit the combo count threshold, we add a smaller boost because it
// makes sense and makes the search more efficient
Some(combo_match) => combo_match.open_count as i32 * 5,
_ => 0,
}
};
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)
.saturating_add(combo_match_boost);
let score = Score {
total,
base_score,
current_file_penalty,
filename_bonus,
special_filename_bonus: if has_special_filename_bonus {
filename_bonus
} else {
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",
},
};
(file, score)
})
.collect();
sort_and_paginate(results, context)
}
/// Check if a filename is a special entry point file that deserves bonus scoring
/// These are typically files that serve as module exports or entry points
fn is_special_entry_point_file(filename: &str) -> bool {
matches!(
filename,
"mod.rs"
| "lib.rs"
| "main.rs"
| "index.js"
| "index.jsx"
| "index.ts"
| "index.tsx"
| "index.mjs"
| "index.cjs"
| "index.vue"
| "__init__.py"
| "__main__.py"
| "main.go"
| "main.c"
| "index.php"
| "main.rb"
| "index.rb"
)
}
/// Score files by frecency when we have a filtered list (prefiltered by constraints)
pub(crate) fn score_filtered_by_frecency<'a>(
files: &FileItems<'a>,
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let score_file = |file: &'a FileItem| {
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(git_status_boost)
.saturating_add(current_file_penalty);
let score = Score {
total,
base_score: 0,
filename_bonus: 0,
distance_penalty: 0,
special_filename_bonus: 0,
combo_match_boost: 0,
current_file_penalty,
frecency_boost: total_frecency_score,
git_status_boost,
exact_match: false,
match_type: "frecency",
};
(file, score)
};
let results: Vec<_> = match files {
FileItems::All(s) => s.par_iter().map(&score_file).collect(),
FileItems::Filtered(v) => v.iter().map(|&file| score_file(file)).collect(),
};
sort_and_paginate(results, context)
}
#[inline]
fn calculate_current_file_penalty(
file: &FileItem,
base_score: i32,
context: &ScoringContext,
) -> i32 {
let mut penalty = 0i32;
if let Some(current) = context.current_file
&& file.relative_path.as_str() == current
{
penalty -= match file.git_status {
Some(status) if is_modified_status(status) => base_score / 2,
_ => base_score,
};
}
penalty
}
/// Sorts elements by total score (descending) and returns the requested page.
/// Always returns results in descending order (best scores first).
/// The UI layer handles rendering order based on prompt position.
#[tracing::instrument(skip_all, level = tracing::Level::DEBUG)]
fn sort_and_paginate<'a>(
mut results: Vec<(&'a FileItem, Score)>,
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let total_matched = results.len();
if total_matched == 0 {
return (vec![], vec![], 0);
}
let offset = context.pagination.offset;
let limit = if context.pagination.limit > 0 {
context.pagination.limit
} else {
total_matched
};
// Check if offset is out of bounds
if offset >= total_matched {
tracing::warn!(
offset = offset,
total_matched = total_matched,
"Pagination: offset >= total_matched, returning empty"
);
return (vec![], vec![], total_matched);
}
let items_needed = offset.saturating_add(limit).min(total_matched);
// Use partial sort if we need less than half the results and dataset is large
let use_partial_sort = items_needed < total_matched / 2 && total_matched > 100;
// Always sort in descending order (best scores first)
if use_partial_sort {
// Partition at position (items_needed - 1) with descending comparator
// This puts the highest N needed items at the front
results.select_nth_unstable_by(items_needed - 1, |a, b| {
b.1.total
.cmp(&a.1.total)
.then_with(|| b.0.modified.cmp(&a.0.modified))
});
results.truncate(items_needed);
}
// select nth does not sort the results, we have to sort accordingly anyway
sort_with_buffer(&mut results, |a, b| {
b.1.total
.cmp(&a.1.total)
.then_with(|| b.0.modified.cmp(&a.0.modified))
});
// in the best scenario truncation happened in the select_nth step
if results.len() > limit {
let page_end = std::cmp::min(offset + limit, results.len());
let page_size = page_end - offset;
results.drain(0..offset);
results.truncate(page_size);
}
let (items, scores): (Vec<&FileItem>, Vec<Score>) = results.into_iter().unzip();
(items, scores, total_matched)
}
#[cfg(test)]
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('/').next_back().unwrap_or(path).to_string();
let file = FileItem::new_raw(
PathBuf::from(path),
path.to_string(),
file_name,
0,
modified,
None,
false,
);
let score_obj = Score {
total: score,
base_score: score,
filename_bonus: 0,
distance_penalty: 0,
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,
};
(file, score_obj)
}
#[test]
fn test_partial_sort_descending() {
// Create test data with known scores
let test_data = vec![
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
create_test_file("file4.rs", 300, 4000),
create_test_file("file5.rs", 150, 5000),
create_test_file("file6.rs", 250, 6000),
create_test_file("file7.rs", 80, 7000),
create_test_file("file8.rs", 180, 8000),
create_test_file("file9.rs", 120, 9000),
create_test_file("file10.rs", 90, 10000),
];
// Convert to references like the actual function uses
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
// Test with full sort - returns all results sorted descending
let (items, scores, total) = sort_and_paginate(results.clone(), &context);
// Should return all 10 items sorted by score descending
assert_eq!(total, 10);
assert_eq!(scores.len(), 10);
assert_eq!(scores[0].total, 300, "First should be highest score");
assert_eq!(scores[1].total, 250, "Second should be second highest");
assert_eq!(scores[2].total, 200, "Third should be third highest");
// Verify the files match
assert_eq!(items[0].relative_path, "file4.rs");
assert_eq!(items[1].relative_path, "file6.rs");
assert_eq!(items[2].relative_path, "file2.rs");
}
#[test]
fn test_partial_sort_with_same_scores() {
// Test tiebreaker with modified time
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
create_test_file("file4.rs", 200, 1000),
create_test_file("file5.rs", 200, 9000), // Higher score, newest
];
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
let (items, scores, _) = sort_and_paginate(results, &context);
// Should return all 5 items sorted: 200(9000), 200(1000), 100(8000), 100(5000), 100(3000)
assert_eq!(scores.len(), 5);
assert_eq!(scores[0].total, 200);
assert_eq!(items[0].modified, 9000, "First 200 should be newest");
assert_eq!(scores[1].total, 200);
assert_eq!(items[1].modified, 1000, "Second 200 should be older");
assert_eq!(scores[2].total, 100);
assert_eq!(items[2].modified, 8000, "First 100 should be newest");
assert_eq!(scores[3].total, 100);
assert_eq!(items[3].modified, 5000);
assert_eq!(scores[4].total, 100);
assert_eq!(items[4].modified, 3000, "Last 100 should be oldest");
}
#[test]
fn test_no_partial_sort_for_small_results() {
// When results.len() <= threshold, should use regular sort
let test_data = [
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
];
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
// Returns all results sorted descending
let (items, scores, _) = sort_and_paginate(results, &context);
assert_eq!(scores.len(), 3);
assert_eq!(scores[0].total, 200);
assert_eq!(scores[1].total, 100);
assert_eq!(scores[2].total, 50);
assert_eq!(items[0].relative_path, "file2.rs");
assert_eq!(items[1].relative_path, "file1.rs");
assert_eq!(items[2].relative_path, "file3.rs");
}
}
#[cfg(test)]
mod multi_part_tests {
#[test]
fn test_single_path_matching() {
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs";
// Test with max_typos = 2 (safe for short needles)
let options = neo_frizbee::Config {
max_typos: Some(2),
sort: false,
..Default::default()
};
// Test "aipart" matching
let matches = neo_frizbee::match_list("aipart", &[path], &options);
println!("'aipart' matches (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'aipart' should match the path");
// Test "core" matching
let matches = neo_frizbee::match_list("core", &[path], &options);
println!("'core' matches (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'core' should match the path");
// Test "co" matching - need max_typos <= needle.len()
let co_options = neo_frizbee::Config {
max_typos: Some(2), // Safe: 2 <= len("co") = 2
..options
};
let matches = neo_frizbee::match_list("co", &[path], &co_options);
println!("'co' matches (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'co' should match the path");
}
#[test]
fn test_lowercase_path_matching() {
// The actual paths are lowercased
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 {
max_typos: Some(2),
sort: false,
..Default::default()
};
// Test "co" matching on lowercase path
let matches = neo_frizbee::match_list("co", &[path.as_str()], &options);
println!("'co' matches lowercase path (max_typos=2): {:?}", matches);
assert!(!matches.is_empty(), "'co' should match the lowercase path");
// Test "core" matching on lowercase path
let matches = neo_frizbee::match_list("core", &[path.as_str()], &options);
println!("'core' matches lowercase path (max_typos=2): {:?}", matches);
assert!(
!matches.is_empty(),
"'core' should match the lowercase path"
);
}
}
@@ -147,7 +147,6 @@ mod tests {
// Simple test to verify highest scores come first
let mut data = vec![100, 300, 200];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
println!("After sort: {:?}", data);
assert_eq!(data[0], 300, "Highest should be first");
assert_eq!(data[1], 200, "Middle should be second");
assert_eq!(data[2], 100, "Lowest should be last");
+260
View File
@@ -0,0 +1,260 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
/// Cached file contents — mmap on Unix, heap buffer on Windows.
///
/// 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`.
#[derive(Debug)]
pub struct FileItem {
pub path: PathBuf,
pub relative_path: String,
pub relative_path_lower: String,
pub file_name: String,
pub file_name_lower: String,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
pub git_status: Option<git2::Status>,
pub is_binary: bool,
/// Lazily-initialized file contents for grep.
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
content: OnceLock<FileContent>,
}
impl Clone for FileItem {
fn clone(&self) -> Self {
Self {
path: self.path.clone(),
relative_path: self.relative_path.clone(),
relative_path_lower: self.relative_path_lower.clone(),
file_name: self.file_name.clone(),
file_name_lower: self.file_name_lower.clone(),
size: self.size,
modified: self.modified,
access_frecency_score: self.access_frecency_score,
modification_frecency_score: self.modification_frecency_score,
total_frecency_score: self.total_frecency_score,
git_status: self.git_status,
is_binary: self.is_binary,
// Don't clone the content — the clone lazily re-creates it on demand
content: OnceLock::new(),
}
}
}
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
impl FileItem {
/// Create a new `FileItem` with all fields specified and an empty (not yet loaded) mmap.
pub fn new_raw(
path: PathBuf,
relative_path: String,
file_name: String,
size: u64,
modified: u64,
git_status: Option<git2::Status>,
is_binary: bool,
) -> Self {
Self {
relative_path_lower: relative_path.to_lowercase(),
file_name_lower: file_name.to_lowercase(),
path,
relative_path,
file_name,
size,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status,
is_binary,
content: OnceLock::new(),
}
}
/// 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.
/// 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.content = OnceLock::new();
}
/// 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).
/// 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_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 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 { memmap2::Mmap::map(&file) }.ok()?;
Some(FileContent::Mmap(mmap))
}
#[cfg(target_os = "windows")]
{
let data = std::fs::read(path).ok()?;
Some(FileContent::Buffer(data))
}
}
impl Constrainable for FileItem {
#[inline]
fn relative_path(&self) -> &str {
&self.relative_path
}
#[inline]
fn relative_path_lower(&self) -> &str {
&self.relative_path_lower
}
#[inline]
fn file_name(&self) -> &str {
&self.file_name
}
#[inline]
fn git_status(&self) -> Option<git2::Status> {
self.git_status
}
}
#[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,
pub exact_match: bool,
pub match_type: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct PaginationArgs {
pub offset: usize,
pub limit: usize,
}
impl Default for PaginationArgs {
fn default() -> Self {
Self {
offset: 0,
limit: 100,
}
}
}
/// Context for scoring files during search.
///
/// 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> {
/// 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,
pub max_threads: usize,
pub last_same_query_match: Option<QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
}
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) -> &str {
match &self.query.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.query.raw_query.trim(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SearchResult<'a> {
pub items: Vec<&'a FileItem>,
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<Location>,
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fff-grep"
description = "File grepping logic for fff"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
version = "0.2.4"
edition = "2024"
[dependencies]
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
grep-matcher = { workspace = true }
memchr = "2.6.3"
+17
View File
@@ -0,0 +1,17 @@
/*!
Simplified grep-searcher for fff.nvim.
Provides line-oriented search over byte slices with optional multi-line support.
Only `search_slice` is supported -- no file/reader/mmap search.
*/
#![deny(missing_docs)]
pub use crate::{
searcher::{Searcher, SearcherBuilder},
sink::{Sink, SinkError, SinkFinish, SinkMatch},
};
pub mod lines;
mod searcher;
mod sink;
+234
View File
@@ -0,0 +1,234 @@
/*!
A collection of routines for performing operations on lines.
*/
use {
bstr::ByteSlice,
grep_matcher::{LineTerminator, Match},
};
/// An explicit iterator over lines in a particular slice of bytes.
///
/// This iterator avoids borrowing the bytes themselves, and instead requires
/// callers to explicitly provide the bytes when moving through the iterator.
///
/// Line terminators are considered part of the line they terminate. All lines
/// yielded by the iterator are guaranteed to be non-empty.
#[derive(Debug)]
pub struct LineStep {
line_term: u8,
pos: usize,
end: usize,
}
impl LineStep {
/// Create a new line iterator over the given range of bytes using the
/// given line terminator.
pub fn new(line_term: u8, start: usize, end: usize) -> LineStep {
LineStep {
line_term,
pos: start,
end,
}
}
/// Like next, but returns a `Match` instead of a tuple.
#[inline(always)]
pub fn next_match(&mut self, bytes: &[u8]) -> Option<Match> {
self.next_impl(bytes).map(|(s, e)| Match::new(s, e))
}
#[inline(always)]
fn next_impl(&mut self, mut bytes: &[u8]) -> Option<(usize, usize)> {
bytes = &bytes[..self.end];
match bytes[self.pos..].find_byte(self.line_term) {
None => {
if self.pos < bytes.len() {
let m = (self.pos, bytes.len());
assert!(m.0 <= m.1);
self.pos = m.1;
Some(m)
} else {
None
}
}
Some(line_end) => {
let m = (self.pos, self.pos + line_end + 1);
assert!(m.0 <= m.1);
self.pos = m.1;
Some(m)
}
}
}
}
/// Count the number of occurrences of `line_term` in `bytes`.
pub fn count(bytes: &[u8], line_term: u8) -> u64 {
memchr::memchr_iter(line_term, bytes).count() as u64
}
/// Given a line that possibly ends with a terminator, return that line without
/// the terminator.
#[inline(always)]
pub fn without_terminator(bytes: &[u8], line_term: LineTerminator) -> &[u8] {
let line_term = line_term.as_bytes();
let start = bytes.len().saturating_sub(line_term.len());
if bytes.get(start..) == Some(line_term) {
return &bytes[..bytes.len() - line_term.len()];
}
bytes
}
/// Return the start and end offsets of the lines containing the given range
/// of bytes.
///
/// Line terminators are considered part of the line they terminate.
#[inline(always)]
pub fn locate(bytes: &[u8], line_term: u8, range: Match) -> Match {
let line_start = bytes[..range.start()]
.rfind_byte(line_term)
.map_or(0, |i| i + 1);
let line_end = if range.end() > line_start && bytes[range.end() - 1] == line_term {
range.end()
} else {
bytes[range.end()..]
.find_byte(line_term)
.map_or(bytes.len(), |i| range.end() + i + 1)
};
Match::new(line_start, line_end)
}
#[cfg(test)]
mod tests {
use super::*;
const SHERLOCK: &'static str = "\
For the Doctor Watsons of this world, as opposed to the Sherlock
Holmeses, success in the province of detective work must always
be, to a very large extent, the result of luck. Sherlock Holmes
can extract a clew from a wisp of straw or a flake of cigar ash;
but Doctor Watson has to have it taken out for him and dusted,
and exhibited clearly, with a label attached.\
";
fn m(start: usize, end: usize) -> Match {
Match::new(start, end)
}
fn lines(text: &str) -> Vec<&str> {
let mut results = vec![];
let mut it = LineStep::new(b'\n', 0, text.len());
while let Some(m) = it.next_match(text.as_bytes()) {
results.push(&text[m]);
}
results
}
fn line_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
let mut results = vec![];
let mut it = LineStep::new(b'\n', 0, text.len());
while let Some(m) = it.next_match(text.as_bytes()) {
results.push(m.start()..m.end());
}
results
}
fn loc(text: &str, start: usize, end: usize) -> Match {
locate(text.as_bytes(), b'\n', Match::new(start, end))
}
#[test]
fn line_count() {
assert_eq!(0, count(b"", b'\n'));
assert_eq!(1, count(b"\n", b'\n'));
assert_eq!(2, count(b"\n\n", b'\n'));
assert_eq!(2, count(b"a\nb\nc", b'\n'));
}
#[test]
fn line_locate() {
let t = SHERLOCK;
let lines = line_ranges(t);
assert_eq!(
loc(t, lines[0].start, lines[0].end),
m(lines[0].start, lines[0].end)
);
assert_eq!(
loc(t, lines[0].start + 1, lines[0].end),
m(lines[0].start, lines[0].end)
);
assert_eq!(
loc(t, lines[0].end - 1, lines[0].end),
m(lines[0].start, lines[0].end)
);
assert_eq!(
loc(t, lines[0].end, lines[0].end),
m(lines[1].start, lines[1].end)
);
assert_eq!(
loc(t, lines[5].start, lines[5].end),
m(lines[5].start, lines[5].end)
);
assert_eq!(
loc(t, lines[5].start + 1, lines[5].end),
m(lines[5].start, lines[5].end)
);
assert_eq!(
loc(t, lines[5].end - 1, lines[5].end),
m(lines[5].start, lines[5].end)
);
assert_eq!(
loc(t, lines[5].end, lines[5].end),
m(lines[5].start, lines[5].end)
);
}
#[test]
fn line_locate_weird() {
assert_eq!(loc("", 0, 0), m(0, 0));
assert_eq!(loc("\n", 0, 1), m(0, 1));
assert_eq!(loc("\n", 1, 1), m(1, 1));
assert_eq!(loc("\n\n", 0, 0), m(0, 1));
assert_eq!(loc("\n\n", 0, 1), m(0, 1));
assert_eq!(loc("\n\n", 1, 1), m(1, 2));
assert_eq!(loc("\n\n", 1, 2), m(1, 2));
assert_eq!(loc("\n\n", 2, 2), m(2, 2));
assert_eq!(loc("a\nb\nc", 0, 1), m(0, 2));
assert_eq!(loc("a\nb\nc", 1, 2), m(0, 2));
assert_eq!(loc("a\nb\nc", 2, 3), m(2, 4));
assert_eq!(loc("a\nb\nc", 3, 4), m(2, 4));
assert_eq!(loc("a\nb\nc", 4, 5), m(4, 5));
assert_eq!(loc("a\nb\nc", 5, 5), m(4, 5));
}
#[test]
fn line_iter() {
assert_eq!(lines("abc"), vec!["abc"]);
assert_eq!(lines("abc\n"), vec!["abc\n"]);
assert_eq!(lines("abc\nxyz"), vec!["abc\n", "xyz"]);
assert_eq!(lines("abc\nxyz\n"), vec!["abc\n", "xyz\n"]);
assert_eq!(lines("abc\n\n"), vec!["abc\n", "\n"]);
assert_eq!(lines("abc\n\n\n"), vec!["abc\n", "\n", "\n"]);
assert_eq!(lines("abc\n\nxyz"), vec!["abc\n", "\n", "xyz"]);
assert_eq!(lines("abc\n\nxyz\n"), vec!["abc\n", "\n", "xyz\n"]);
assert_eq!(lines("abc\nxyz\n\n"), vec!["abc\n", "xyz\n", "\n"]);
assert_eq!(lines("\n"), vec!["\n"]);
assert_eq!(lines(""), Vec::<&str>::new());
}
#[test]
fn line_iter_empty() {
let mut it = LineStep::new(b'\n', 0, 0);
assert_eq!(it.next_match(b"abc"), None);
}
}
+212
View File
@@ -0,0 +1,212 @@
use grep_matcher::{LineMatchKind, Matcher};
use crate::{
lines::{self, LineStep},
searcher::{Config, Range, Searcher},
sink::{Sink, SinkError, SinkFinish, SinkMatch},
};
#[derive(Debug)]
pub(crate) struct Core<'s, M: 's, S> {
config: &'s Config,
matcher: M,
searcher: &'s Searcher,
sink: S,
pos: usize,
absolute_byte_offset: u64,
line_number: Option<u64>,
last_line_counted: usize,
last_line_visited: usize,
}
impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
pub(crate) fn new(searcher: &'s Searcher, matcher: M, sink: S) -> Core<'s, M, S> {
let line_number = if searcher.config.line_number {
Some(1)
} else {
None
};
Core {
config: &searcher.config,
matcher,
searcher,
sink,
pos: 0,
absolute_byte_offset: 0,
line_number,
last_line_counted: 0,
last_line_visited: 0,
}
}
pub(crate) fn pos(&self) -> usize {
self.pos
}
pub(crate) fn set_pos(&mut self, pos: usize) {
self.pos = pos;
}
pub(crate) fn matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
self.sink_matched(buf, range)
}
pub(crate) fn find(&mut self, slice: &[u8]) -> Result<Option<Range>, S::Error> {
match self.matcher.find(slice) {
Err(err) => Err(S::Error::error_message(err)),
Ok(m) => Ok(m),
}
}
fn shortest_match(&mut self, slice: &[u8]) -> Result<Option<usize>, S::Error> {
match self.matcher.shortest_match(slice) {
Err(err) => Err(S::Error::error_message(err)),
Ok(m) => Ok(m),
}
}
pub(crate) fn begin(&mut self) -> Result<bool, S::Error> {
self.sink.begin(self.searcher)
}
pub(crate) fn finish(&mut self, byte_count: u64) -> Result<(), S::Error> {
self.sink.finish(self.searcher, &SinkFinish { byte_count })
}
pub(crate) fn match_by_line(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
if self.is_line_by_line_fast() {
self.match_by_line_fast(buf)
} else {
self.match_by_line_slow(buf)
}
}
fn match_by_line_slow(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
let range = Range::new(self.pos(), buf.len());
let mut stepper =
LineStep::new(self.config.line_term.as_byte(), range.start(), range.end());
while let Some(line) = stepper.next_match(buf) {
let matched = {
let slice = lines::without_terminator(&buf[line], self.config.line_term);
self.shortest_match(slice)?.is_some()
};
self.set_pos(line.end());
if matched && !self.sink_matched(buf, &line)? {
return Ok(false);
}
}
Ok(true)
}
fn match_by_line_fast(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
while !buf[self.pos()..].is_empty() {
if let Some(line) = self.find_by_line_fast(buf)? {
self.set_pos(line.end());
if !self.sink_matched(buf, &line)? {
return Ok(false);
}
} else {
break;
}
}
self.set_pos(buf.len());
Ok(true)
}
#[inline(always)]
fn find_by_line_fast(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
debug_assert!(self.is_line_by_line_fast());
let mut pos = self.pos();
while !buf[pos..].is_empty() {
match self.matcher.find_candidate_line(&buf[pos..]) {
Err(err) => return Err(S::Error::error_message(err)),
Ok(None) => return Ok(None),
Ok(Some(LineMatchKind::Confirmed(i))) => {
let line = lines::locate(
buf,
self.config.line_term.as_byte(),
Range::zero(i).offset(pos),
);
if line.start() == buf.len() {
pos = buf.len();
continue;
}
return Ok(Some(line));
}
Ok(Some(LineMatchKind::Candidate(i))) => {
let line = lines::locate(
buf,
self.config.line_term.as_byte(),
Range::zero(i).offset(pos),
);
let slice = lines::without_terminator(&buf[line], self.config.line_term);
if self
.matcher
.is_match(slice)
.map_err(S::Error::error_message)?
{
return Ok(Some(line));
}
pos = line.end();
}
}
}
Ok(None)
}
#[inline(always)]
fn sink_matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
self.count_lines(buf, range.start());
let offset = self.absolute_byte_offset + range.start() as u64;
let linebuf = &buf[*range];
let keepgoing = self.sink.matched(
self.searcher,
&SinkMatch {
bytes: linebuf,
absolute_byte_offset: offset,
line_number: self.line_number,
buffer: buf,
bytes_range_in_buffer: range.start()..range.end(),
},
)?;
if !keepgoing {
return Ok(false);
}
self.last_line_visited = range.end();
Ok(true)
}
fn count_lines(&mut self, buf: &[u8], upto: usize) {
if let Some(ref mut line_number) = self.line_number {
if self.last_line_counted >= upto {
return;
}
let slice = &buf[self.last_line_counted..upto];
let count = lines::count(slice, self.config.line_term.as_byte());
*line_number += count;
self.last_line_counted = upto;
}
}
fn is_line_by_line_fast(&self) -> bool {
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
if let Some(line_term) = self.matcher.line_terminator() {
if line_term.as_byte() == b'\x00' {
return false;
}
if line_term == self.config.line_term {
return true;
}
}
if let Some(non_matching) = self.matcher.non_matching_bytes()
&& non_matching.contains(self.config.line_term.as_byte())
{
return true;
}
false
}
}
+128
View File
@@ -0,0 +1,128 @@
use grep_matcher::Matcher;
use crate::{
lines,
searcher::{Config, Range, Searcher, core::Core},
sink::Sink,
};
#[derive(Debug)]
pub(crate) struct SliceByLine<'s, M, S> {
core: Core<'s, M, S>,
slice: &'s [u8],
}
impl<'s, M: Matcher, S: Sink> SliceByLine<'s, M, S> {
pub(crate) fn new(
searcher: &'s Searcher,
matcher: M,
slice: &'s [u8],
write_to: S,
) -> SliceByLine<'s, M, S> {
debug_assert!(!searcher.multi_line_with_matcher(&matcher));
SliceByLine {
core: Core::new(searcher, matcher, write_to),
slice,
}
}
pub(crate) fn run(mut self) -> Result<(), S::Error> {
if self.core.begin()? {
while !self.slice[self.core.pos()..].is_empty()
&& self.core.match_by_line(self.slice)?
{}
}
let byte_count = self.slice.len() as u64;
self.core.finish(byte_count)
}
}
#[derive(Debug)]
pub(crate) struct MultiLine<'s, M, S> {
config: &'s Config,
core: Core<'s, M, S>,
slice: &'s [u8],
last_match: Option<Range>,
}
impl<'s, M: Matcher, S: Sink> MultiLine<'s, M, S> {
pub(crate) fn new(
searcher: &'s Searcher,
matcher: M,
slice: &'s [u8],
write_to: S,
) -> MultiLine<'s, M, S> {
debug_assert!(searcher.multi_line_with_matcher(&matcher));
MultiLine {
config: &searcher.config,
core: Core::new(searcher, matcher, write_to),
slice,
last_match: None,
}
}
pub(crate) fn run(mut self) -> Result<(), S::Error> {
if self.core.begin()? {
let mut keepgoing = true;
while !self.slice[self.core.pos()..].is_empty() && keepgoing {
keepgoing = self.sink()?;
}
if keepgoing && let Some(last_match) = self.last_match.take() {
self.sink_matched(&last_match)?;
}
}
let byte_count = self.slice.len() as u64;
self.core.finish(byte_count)
}
fn sink(&mut self) -> Result<bool, S::Error> {
let mat = match self.find()? {
Some(range) => range,
None => {
self.core.set_pos(self.slice.len());
return Ok(true);
}
};
self.advance(&mat);
let line = lines::locate(self.slice, self.config.line_term.as_byte(), mat);
match self.last_match.take() {
None => {
self.last_match = Some(line);
Ok(true)
}
Some(last_match) => {
if last_match.end() >= line.start() {
self.last_match = Some(last_match.with_end(line.end()));
Ok(true)
} else {
self.last_match = Some(line);
self.sink_matched(&last_match)
}
}
}
}
fn sink_matched(&mut self, range: &Range) -> Result<bool, S::Error> {
if range.is_empty() {
return Ok(false);
}
self.core.matched(self.slice, range)
}
fn find(&mut self) -> Result<Option<Range>, S::Error> {
self.core
.find(&self.slice[self.core.pos()..])
.map(|m| m.map(|m| m.offset(self.core.pos())))
}
fn advance(&mut self, range: &Range) {
self.core.set_pos(range.end());
if range.is_empty() && self.core.pos() < self.slice.len() {
let newpos = self.core.pos() + 1;
self.core.set_pos(newpos);
}
}
}
+200
View File
@@ -0,0 +1,200 @@
use grep_matcher::{LineTerminator, Match, Matcher};
use crate::{
searcher::glue::{MultiLine, SliceByLine},
sink::{Sink, SinkError},
};
mod core;
mod glue;
/// We use this type alias since we want the ergonomics of a matcher's `Match`
/// type, but in practice, we use it for arbitrary ranges, so give it a more
/// accurate name. This is only used in the searcher's internals.
type Range = Match;
/// An error that can occur when building a searcher.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub(crate) enum ConfigError {
/// Occurs when a matcher reports a line terminator that is different than
/// the one configured in the searcher.
MismatchedLineTerminators {
/// The matcher's line terminator.
matcher: LineTerminator,
/// The searcher's line terminator.
searcher: LineTerminator,
},
}
impl std::error::Error for ConfigError {}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
ConfigError::MismatchedLineTerminators { matcher, searcher } => {
write!(
f,
"grep config error: mismatched line terminators, \
matcher has {:?} but searcher has {:?}",
matcher, searcher
)
}
}
}
}
/// The internal configuration of a searcher.
#[derive(Clone, Debug)]
pub(crate) struct Config {
/// The line terminator to use.
pub(crate) line_term: LineTerminator,
/// Whether to count line numbers.
pub(crate) line_number: bool,
/// Whether to enable matching across multiple lines.
multi_line: bool,
}
impl Default for Config {
fn default() -> Config {
Config {
line_term: LineTerminator::default(),
line_number: true,
multi_line: false,
}
}
}
/// A builder for configuring a searcher.
#[derive(Clone, Debug)]
pub struct SearcherBuilder {
config: Config,
}
impl Default for SearcherBuilder {
fn default() -> SearcherBuilder {
SearcherBuilder::new()
}
}
impl SearcherBuilder {
/// Create a new searcher builder with a default configuration.
pub fn new() -> SearcherBuilder {
SearcherBuilder {
config: Config::default(),
}
}
/// Build a searcher.
pub fn build(&self) -> Searcher {
Searcher {
config: self.config.clone(),
}
}
/// Whether to count and include line numbers with matching lines.
pub fn line_number(&mut self, yes: bool) -> &mut SearcherBuilder {
self.config.line_number = yes;
self
}
/// Whether to enable multi line search or not.
pub fn multi_line(&mut self, yes: bool) -> &mut SearcherBuilder {
self.config.multi_line = yes;
self
}
}
/// A searcher executes searches over a haystack and writes results to a caller
/// provided sink.
#[derive(Clone, Debug)]
pub struct Searcher {
pub(crate) config: Config,
}
impl Searcher {
/// Create a new searcher with a default configuration.
pub fn new() -> Searcher {
SearcherBuilder::new().build()
}
/// Execute a search over the given slice and write the results to the
/// given sink.
pub fn search_slice<M, S>(&self, matcher: M, slice: &[u8], write_to: S) -> Result<(), S::Error>
where
M: Matcher,
S: Sink,
{
self.check_config(&matcher)
.map_err(S::Error::error_message)?;
if self.multi_line_with_matcher(&matcher) {
MultiLine::new(self, matcher, slice, write_to).run()
} else {
SliceByLine::new(self, matcher, slice, write_to).run()
}
}
/// Check that the searcher's configuration and the matcher are consistent.
fn check_config<M: Matcher>(&self, matcher: M) -> Result<(), ConfigError> {
let matcher_line_term = match matcher.line_terminator() {
None => return Ok(()),
Some(line_term) => line_term,
};
if matcher_line_term != self.config.line_term {
return Err(ConfigError::MismatchedLineTerminators {
matcher: matcher_line_term,
searcher: self.config.line_term,
});
}
Ok(())
}
}
impl Default for Searcher {
fn default() -> Self {
Self::new()
}
}
/// Configuration query methods used by the sink and internal search core.
impl Searcher {
/// Returns the line terminator used by this searcher.
#[inline]
pub fn line_terminator(&self) -> LineTerminator {
self.config.line_term
}
/// Returns true if and only if this searcher is configured to count line
/// numbers.
#[inline]
pub fn line_number(&self) -> bool {
self.config.line_number
}
/// Returns true if and only if this searcher is configured to perform
/// multi line search.
#[inline]
pub fn multi_line(&self) -> bool {
self.config.multi_line
}
/// Returns true if and only if this searcher will choose a multi-line
/// strategy given the provided matcher.
pub fn multi_line_with_matcher<M: Matcher>(&self, matcher: M) -> bool {
if !self.multi_line() {
return false;
}
if let Some(line_term) = matcher.line_terminator()
&& line_term == self.line_terminator()
{
return false;
}
if let Some(non_matching) = matcher.non_matching_bytes()
&& non_matching.contains(self.line_terminator().as_byte())
{
return false;
}
true
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::io;
use crate::searcher::Searcher;
/// A trait that describes errors that can be reported by searchers and
/// implementations of `Sink`.
pub trait SinkError: Sized {
/// A constructor for converting any value that satisfies the
/// `std::fmt::Display` trait into an error.
fn error_message<T: std::fmt::Display>(message: T) -> Self;
/// A constructor for converting I/O errors that occur while searching into
/// an error of this type.
fn error_io(err: io::Error) -> Self {
Self::error_message(err)
}
}
impl SinkError for io::Error {
fn error_message<T: std::fmt::Display>(message: T) -> io::Error {
io::Error::other(message.to_string())
}
fn error_io(err: io::Error) -> io::Error {
err
}
}
/// A trait that defines how results from searchers are handled.
///
/// The searcher follows the "push" model: the searcher drives execution and
/// pushes results back to the caller via this trait.
pub trait Sink {
/// The type of an error that should be reported by a searcher.
type Error: SinkError;
/// This method is called whenever a match is found.
///
/// If this returns `true`, then searching continues. If this returns
/// `false`, then searching is stopped immediately and `finish` is called.
fn matched(&mut self, _searcher: &Searcher, _mat: &SinkMatch<'_>) -> Result<bool, Self::Error>;
/// This method is called when a search has begun, before any search is
/// executed. By default, this does nothing.
#[inline]
fn begin(&mut self, _searcher: &Searcher) -> Result<bool, Self::Error> {
Ok(true)
}
/// This method is called when a search has completed. By default, this
/// does nothing.
#[inline]
fn finish(&mut self, _searcher: &Searcher, _: &SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
impl<S: Sink> Sink for &mut S {
type Error = S::Error;
#[inline]
fn matched(&mut self, searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, S::Error> {
(**self).matched(searcher, mat)
}
#[inline]
fn begin(&mut self, searcher: &Searcher) -> Result<bool, S::Error> {
(**self).begin(searcher)
}
#[inline]
fn finish(&mut self, searcher: &Searcher, sink_finish: &SinkFinish) -> Result<(), S::Error> {
(**self).finish(searcher, sink_finish)
}
}
/// Summary data reported at the end of a search.
#[derive(Clone, Debug)]
pub struct SinkFinish {
pub(crate) byte_count: u64,
}
impl SinkFinish {
/// Return the total number of bytes searched.
#[inline]
pub fn byte_count(&self) -> u64 {
self.byte_count
}
}
/// A type that describes a match reported by a searcher.
#[derive(Clone, Debug)]
pub struct SinkMatch<'b> {
pub(crate) bytes: &'b [u8],
pub(crate) absolute_byte_offset: u64,
pub(crate) line_number: Option<u64>,
pub(crate) buffer: &'b [u8],
pub(crate) bytes_range_in_buffer: std::ops::Range<usize>,
}
impl<'b> SinkMatch<'b> {
/// Returns the bytes for all matching lines, including the line
/// terminators, if they exist.
#[inline]
pub fn bytes(&self) -> &'b [u8] {
self.bytes
}
/// Returns the absolute byte offset of the start of this match. This
/// offset is absolute in that it is relative to the very beginning of the
/// input in a search.
#[inline]
pub fn absolute_byte_offset(&self) -> u64 {
self.absolute_byte_offset
}
/// Returns the line number of the first line in this match, if available.
///
/// Line numbers are only available when the search builder is instructed
/// to compute them.
#[inline]
pub fn line_number(&self) -> Option<u64> {
self.line_number
}
/// Exposes as much of the underlying buffer that was searched as possible.
#[inline]
pub fn buffer(&self) -> &'b [u8] {
self.buffer
}
/// Returns a range that corresponds to where [`SinkMatch::bytes`] appears
/// in [`SinkMatch::buffer`].
#[inline]
pub fn bytes_range_in_buffer(&self) -> std::ops::Range<usize> {
self.bytes_range_in_buffer.clone()
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "fff-mcp"
version = "0.2.4"
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/zlob"]
[dependencies]
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.2.2" }
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.2.4" }
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()
}
}
+296
View File
@@ -0,0 +1,296 @@
//! 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::file_picker::FilePicker;
use fff::frecency::FrecencyTracker;
use fff::{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::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);
}
let _ =
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::GrepMatch;
use fff::git::format_git_status_opt;
use fff::grep::is_import_line;
use fff::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
}
}
+682
View File
@@ -0,0 +1,682 @@
//! 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::file_picker::FilePicker;
use fff::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff::types::{FileItem, PaginationArgs};
use fff::{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()),
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 1,
},
};
let file_result = FilePicker::fuzzy_search(files, &file_query, None, 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),
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, None, 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,
/*query_tracker=*/ None,
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)
}
}
+78
View File
@@ -0,0 +1,78 @@
//! 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)
}
+81
View File
@@ -0,0 +1,81 @@
[package]
name = "fff-nvim"
version = "0.2.4"
edition = "2024"
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[features]
default = []
zlob = ["fff/zlob"]
[[bin]]
name = "test_watcher"
path = "src/bin/test_watcher.rs"
[[bin]]
name = "jemalloc_profile"
path = "src/bin/jemalloc_profile.rs"
[[bin]]
name = "search_profiler"
path = "src/bin/search_profiler.rs"
[[bin]]
name = "bench_search_only"
path = "src/bin/bench_search_only.rs"
[[bin]]
name = "grep_profiler"
path = "src/bin/grep_profiler.rs"
[[bin]]
name = "grep_vs_rg"
path = "src/bin/grep_vs_rg.rs"
[dependencies]
# Workspace dependencies
ahash = { workspace = true }
rayon = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff = { package = "fff-search", path = "../fff-core" , version = "0.2.2" }
fff-query-parser = { path = "../fff-query-parser" , version = "0.2.4" }
# External dependencies
blake3 = "1.8.2"
chrono = { version = "0.4", features = ["serde"] }
ctrlc = "3.4.2"
dirs = "5.0"
git2 = { workspace = true }
glidesort = "0.1"
heed = "0.22.0"
ignore = "0.4.22"
mimalloc = "0.1.47"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { workspace = true }
notify = "8.1.0"
notify-debouncer-full = "0.6"
once_cell = "1.20.2"
pathdiff = "0.2.1"
serde = { version = "1.0", features = ["derive"] }
smartstring = { version = "1.0.1", features = ["serde"] }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand = { version = "0.8", features = ["small_rng"] }
tempfile = "3.8"
[[bench]]
name = "indexing_and_search"
harness = false
[[bench]]
name = "query_tracker_bench"
harness = false
@@ -0,0 +1,668 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff::file_picker::{FFFMode, FilePicker};
use fff::types::{FileItem, PaginationArgs};
use fff::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// Initialize tracing to output to console
fn init_tracing() {
// use tracing_subscriber::EnvFilter;
// use tracing_subscriber::fmt;
// let _ = fmt()
// .with_env_filter(
// EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
// )
// .with_target(false)
// .with_thread_ids(true)
// .with_line_number(true)
// .try_init();
}
/// 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(
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();
let mut iteration = 0;
loop {
iteration += 1;
{
let picker_guard = shared_picker
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let is_scanning = picker.is_scan_active();
let file_count = picker.get_files().len();
// Log progress every 2 seconds
if last_log.elapsed() >= Duration::from_secs(2) {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}, Iterations: {}",
start.elapsed().as_secs_f32(),
is_scanning,
file_count,
iteration
);
last_log = std::time::Instant::now();
}
if !is_scanning && file_count > 0 {
eprintln!(
" ✓ Scan complete after {:.2}s: {} files found",
start.elapsed().as_secs_f32(),
file_count
);
return Ok(file_count);
}
} else {
if iteration % 100 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None (iteration {})",
start.elapsed().as_secs_f32(),
iteration
);
}
}
}
if start.elapsed() > timeout {
return Err(format!(
"Scan timed out after {} seconds (iteration {})",
timeout_secs, iteration
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// 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 {
Ok(picker.get_files().to_vec())
} else {
Err("FilePicker not initialized".to_string())
}
}
/// 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();
}
}
}
/// Initialize FilePicker once and return files snapshot
fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String> {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
}
let canonical_path = fff::path_utils::canonicalize(&big_repo_path)
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
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(&shared_picker, 120)?;
eprintln!(
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
file_count
);
let files = get_files_snapshot(&shared_picker)?;
Ok((files, shared_picker, shared_frecency))
}
/// Benchmark for indexing the big-repo directory
fn bench_indexing(c: &mut Criterion) {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = match fff::path_utils::canonicalize(&big_repo_path) {
Ok(p) => p,
Err(e) => {
eprintln!("⚠ Failed to canonicalize path: {}", e);
return;
}
};
let mut group = c.benchmark_group("indexing");
group.sample_size(10);
group.measurement_time(Duration::from_secs(20));
group.bench_function("index_big_repo", |b| {
b.iter(|| {
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()), &sp, &sf)
.expect("Failed to init FilePicker");
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
}
}
});
});
group.finish();
}
/// Benchmark for searching with various query patterns
fn bench_search_queries(c: &mut Criterion) {
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprint!("Failed to setup picker {e:?}");
return;
}
};
let mut group = c.benchmark_group("search");
group.sample_size(100);
let test_queries = vec![
("short", "mod"),
("medium", "controller"),
("long", "user_authentication"),
("typo", "contrlr"),
("partial", "src/lib"),
];
let parser = QueryParser::default();
for (name, query) in test_queries {
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(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search with different thread counts
fn bench_search_thread_scaling(c: &mut Criterion) {
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("thread_scaling");
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 {
group.bench_with_input(
BenchmarkId::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
FuzzySearchOptions {
max_threads: threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
},
);
}
group.finish();
}
/// Benchmark search with different result limits
fn bench_search_result_limits(c: &mut Criterion) {
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("result_limits");
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 {
group.bench_with_input(BenchmarkId::from_parameter(limit), &limit, |b, &limit| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: limit,
},
},
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search algorithm performance scaling with file count
fn bench_search_scalability(c: &mut Criterion) {
let (all_files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
return;
}
};
if all_files.len() < 1000 {
eprintln!(
"⚠ Skipping scalability benchmark: need at least 1000 files, got {}",
all_files.len()
);
return;
}
let mut group = c.benchmark_group("search_scalability");
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 {
if count > all_files.len() {
continue;
}
let subset = &all_files[..count];
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, _| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search performance with different ordering modes
fn bench_search_ordering(c: &mut Criterion) {
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
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(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
// Benchmark reverse order (ascending)
group.bench_function("reverse_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
// Benchmark with large result set
group.bench_function("normal_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 500,
},
},
);
results.total_matched
});
});
group.bench_function("reverse_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 500,
},
},
);
results.total_matched
});
});
// Benchmark with small result set
group.bench_function("normal_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 10,
},
},
);
results.total_matched
});
});
group.bench_function("reverse_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 10,
},
},
);
results.total_matched
});
});
group.finish();
}
/// Benchmark pagination: first page vs deep page
fn bench_pagination_performance(c: &mut Criterion) {
let (files, _sp, _sf) = match setup_once() {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("pagination");
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)
group.bench_function("page_0_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: page_size,
},
},
);
results.total_matched
});
});
// Benchmark 10th page (requires full sort, no optimization)
group.bench_function("page_10_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 10,
limit: page_size,
},
},
);
results.total_matched
});
});
// Benchmark 50th page (even deeper pagination)
group.bench_function("page_50_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 50,
limit: page_size,
},
},
);
results.total_matched
});
});
group.finish();
}
criterion_group!(
benches,
bench_indexing,
bench_search_queries,
bench_search_thread_scaling,
bench_search_result_limits,
bench_search_scalability,
bench_search_ordering,
bench_pagination_performance,
);
criterion_main!(benches);
@@ -1,5 +1,5 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::query_tracker::{QueryMatchEntry, QueryTracker};
use fff::query_tracker::QueryTracker;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use std::path::PathBuf;
@@ -13,7 +13,16 @@ fn generate_random_string(len: usize) -> String {
.collect()
}
fn generate_test_data(num_entries: usize) -> Vec<QueryMatchEntry> {
// Test data structure for benchmarks
struct TestQueryEntry {
query: String,
project_path: PathBuf,
file_path: PathBuf,
open_count: u32,
last_opened: u64,
}
fn generate_test_data(num_entries: usize) -> Vec<TestQueryEntry> {
let mut rng = thread_rng();
let mut entries = Vec::with_capacity(num_entries);
let now = SystemTime::now()
@@ -72,7 +81,7 @@ fn generate_test_data(num_entries: usize) -> Vec<QueryMatchEntry> {
);
let file_path = PathBuf::from(format!("{}/src/{}", project_path, file_name));
let entry = QueryMatchEntry {
let entry = TestQueryEntry {
query: query.into(),
project_path: PathBuf::from(project_path),
file_path,
@@ -86,7 +95,7 @@ fn generate_test_data(num_entries: usize) -> Vec<QueryMatchEntry> {
entries
}
fn setup_tracker_with_data(entries: &[QueryMatchEntry]) -> (QueryTracker, PathBuf) {
fn setup_tracker_with_data(entries: &[TestQueryEntry]) -> (QueryTracker, PathBuf) {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -144,88 +153,6 @@ fn bench_track_query_completion(c: &mut Criterion) {
group.finish();
}
fn bench_get_query_boost(c: &mut Criterion) {
let mut group = c.benchmark_group("get_query_boost");
for size in &[100, 1000, 10000] {
let entries = generate_test_data(*size);
let (tracker, temp_dir) = setup_tracker_with_data(&entries);
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| {
let mut rng = thread_rng();
b.iter(|| {
let entry = entries.choose(&mut rng).unwrap();
let boost = black_box(
tracker
.get_query_boost(
black_box(&entry.query),
black_box(&entry.project_path),
black_box(&entry.file_path),
)
.unwrap(),
);
black_box(boost);
});
});
drop(tracker);
cleanup_tracker_dir(temp_dir);
}
group.finish();
}
fn bench_get_query_history(c: &mut Criterion) {
let mut group = c.benchmark_group("get_query_history");
for size in &[100, 1000, 10000] {
let entries = generate_test_data(*size);
let (tracker, temp_dir) = setup_tracker_with_data(&entries);
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| {
let mut rng = thread_rng();
b.iter(|| {
let project = &entries.choose(&mut rng).unwrap().project_path;
let history = black_box(
tracker
.get_query_history(black_box(project), black_box(50))
.unwrap(),
);
black_box(history);
});
});
drop(tracker);
cleanup_tracker_dir(temp_dir);
}
group.finish();
}
fn bench_cleanup_old_entries(c: &mut Criterion) {
let mut group = c.benchmark_group("cleanup_old_entries");
for size in &[100, 1000, 10000] {
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, &size| {
b.iter_batched(
|| {
let entries = generate_test_data(size);
setup_tracker_with_data(&entries)
},
|(mut tracker, temp_dir)| {
let cleaned = black_box(tracker.cleanup_old_entries().unwrap());
black_box(cleaned);
drop(tracker);
cleanup_tracker_dir(temp_dir);
},
criterion::BatchSize::LargeInput,
);
});
}
group.finish();
}
fn bench_realistic_workload(c: &mut Criterion) {
let mut group = c.benchmark_group("realistic_workload");
@@ -241,17 +168,17 @@ fn bench_realistic_workload(c: &mut Criterion) {
// Simulate realistic usage: 70% lookups, 25% tracking, 5% history
match rng.gen_range(0..100) {
0..70 => {
// Query boost lookup (most common operation)
let boost = black_box(
// Query entry lookup (most common operation)
let entry_result = black_box(
tracker
.get_query_boost(
.get_last_query_entry(
black_box(&entry.query),
black_box(&entry.project_path),
black_box(&entry.file_path),
3,
)
.unwrap(),
);
black_box(boost);
black_box(entry_result);
}
70..95 => {
// Track completion (when user opens file)
@@ -266,10 +193,10 @@ fn bench_realistic_workload(c: &mut Criterion) {
);
}
95..100 => {
// Get history (least common)
// Get historical query (least common)
let history = black_box(
tracker
.get_query_history(black_box(&entry.project_path), black_box(20))
.get_historical_query(black_box(&entry.project_path), black_box(5))
.unwrap(),
);
black_box(history);
@@ -289,9 +216,9 @@ fn bench_realistic_workload(c: &mut Criterion) {
criterion_group!(
benches,
bench_track_query_completion,
bench_get_query_boost,
bench_get_query_history,
bench_cleanup_old_entries,
// Commented out - methods removed/changed in refactor:
// bench_get_query_boost,
// bench_cleanup_old_entries,
bench_realistic_workload
);
criterion_main!(benches);
@@ -1,5 +1,6 @@
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
use fff_nvim::file_picker::FilePicker;
use fff::file_picker::FilePicker;
use fff::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::time::Instant;
fn main() {
@@ -12,9 +13,8 @@ fn main() {
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
let canonical_path =
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
eprintln!("Loading files from: {:?}", canonical_path);
@@ -37,19 +37,15 @@ fn main() {
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
files.push(fff_nvim::types::FileItem {
files.push(FileItem::new_raw(
path,
relative_path_lower: relative_path.to_lowercase(),
relative_path,
file_name_lower: file_name.to_lowercase(),
file_name,
size: entry.metadata().ok().map_or(0, |m| m.len()),
modified: 0,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status: None,
});
entry.metadata().ok().map_or(0, |m| m.len()),
0,
None,
false,
));
});
files
@@ -87,18 +83,22 @@ fn main() {
let mut match_count = 0;
for _ in 0..iterations {
let parser = QueryParser::default();
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
fff_nvim::file_picker::FuzzySearchOptions {
max_results: 100,
&parsed,
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
reverse_order: false,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
match_count += results.total_matched;
+201
View File
@@ -0,0 +1,201 @@
use fff::FileItem;
/// Fuzzy grep quality test against ~/dev/lightsource
///
/// Runs queries through the fuzzy grep pipeline and prints results
/// so we can verify match quality.
///
/// Usage:
/// cargo run --release --bin fuzzy_grep_test # runs default test queries
/// cargo run --release --bin fuzzy_grep_test -- "query" # runs a single user query
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::time::Instant;
fn load_files(base_path: &Path) -> Vec<FileItem> {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(base_path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.ignore(true)
.follow_links(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.for_each(|entry| {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
size,
0,
None,
is_binary,
));
});
files
}
fn detect_binary(path: &Path, size: u64) -> bool {
if size == 0 {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = std::io::BufReader::with_capacity(1024, file);
let mut buf = [0u8; 512];
let n = reader.read(&mut buf).unwrap_or(0);
buf[..n].contains(&0)
}
fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
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, &parsed, &options);
let elapsed = start.elapsed();
eprintln!("══════════════════════════════════════════════════════════════");
eprintln!(" Query: \"{}\" ({})", query, label);
eprintln!(
" Results: {} matches in {} files ({:.2}ms)",
result.matches.len(),
result.total_files_searched,
elapsed.as_secs_f64() * 1000.0,
);
eprintln!("══════════════════════════════════════════════════════════════");
if result.matches.is_empty() {
eprintln!(" (no matches)\n");
return;
}
// Group by file for readability
let mut current_file_idx = usize::MAX;
for (i, m) in result.matches.iter().enumerate() {
if m.file_index != current_file_idx {
current_file_idx = m.file_index;
let file = &result.files[m.file_index];
eprintln!("\n ┌─ {}", file.relative_path);
}
// Truncate long lines for display
let display_line = if m.line_content.len() > 100 {
format!("{}...", &m.line_content[..100])
} else {
m.line_content.clone()
};
let score_str = m
.fuzzy_score
.map(|s| format!("score={}", s))
.unwrap_or_else(|| "no-score".to_string());
let offsets_str = if m.match_byte_offsets.is_empty() {
String::new()
} else {
// Show what text fragments are highlighted
let fragments: Vec<String> = m
.match_byte_offsets
.iter()
.filter_map(|&(s, e)| {
m.line_content
.get(s as usize..e as usize)
.map(|frag| format!("\"{}\"", frag))
})
.collect();
format!(" hl=[{}]", fragments.join(","))
};
eprintln!(
" │ L{:<5} [{}{}] {}",
m.line_number,
score_str,
offsets_str,
display_line.trim(),
);
// Cap output at 50 lines
if i >= 49 {
let remaining = result.matches.len() - 50;
if remaining > 0 {
eprintln!(" │ ... and {} more matches", remaining);
}
break;
}
}
eprintln!();
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let repo_path = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/Users/neogoose".to_string()),
)
.join("dev/lightsource");
if !repo_path.exists() {
eprintln!("Repository not found at: {:?}", repo_path);
std::process::exit(1);
}
let canonical = fff::path_utils::canonicalize(&repo_path).expect("Failed to canonicalize path");
eprintln!("=== Fuzzy Grep Quality Test ===");
eprintln!("Repository: {:?}\n", canonical);
eprintln!("Loading files...");
let load_start = Instant::now();
let files = load_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary).count();
eprintln!(
"Loaded {} files ({} non-binary) in {:.2}s\n",
files.len(),
non_binary,
load_start.elapsed().as_secs_f64()
);
if args.is_empty() {
// Run default test queries
run_fuzzy_query(&files, "shcema", "transposition of 'schema'");
run_fuzzy_query(&files, "SortedMap", "should match SortedArrayMap");
run_fuzzy_query(
&files,
"struct SortedMap",
"should NOT match SourcingProjectMetadataParts",
);
} else {
// Run user-provided queries
for query in &args {
run_fuzzy_query(&files, query, "user query");
}
}
eprintln!("=== Done ===");
}
+485
View File
@@ -0,0 +1,485 @@
use fff::FileItem;
/// Live grep benchmark profiler for fff.nvim
///
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
/// Measures cold-cache, warm-cache, and incremental typing latencies to simulate
/// real user interaction patterns.
///
/// Uses direct WalkBuilder scanning (no background thread) for faster startup.
///
/// Usage:
/// cargo build --release --bin grep_profiler
/// ./target/release/grep_profiler [--path /path/to/repo]
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::time::{Duration, Instant};
fn load_files(base_path: &Path) -> Vec<FileItem> {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(base_path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.ignore(true)
.follow_links(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.for_each(|entry| {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
size,
0,
None,
is_binary,
));
});
files
}
fn detect_binary(path: &Path, size: u64) -> bool {
if size == 0 {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = std::io::BufReader::with_capacity(1024, file);
let mut buf = [0u8; 512];
let n = reader.read(&mut buf).unwrap_or(0);
buf[..n].contains(&0)
}
struct BenchStats {
times: Vec<Duration>,
}
impl BenchStats {
fn new() -> Self {
Self { times: Vec::new() }
}
fn push(&mut self, d: Duration) {
self.times.push(d);
}
fn mean(&self) -> Duration {
let total: Duration = self.times.iter().sum();
total / self.times.len() as u32
}
fn median(&self) -> Duration {
let mut sorted = self.times.clone();
sorted.sort();
sorted[sorted.len() / 2]
}
fn p95(&self) -> Duration {
let mut sorted = self.times.clone();
sorted.sort();
let idx = ((sorted.len() as f64) * 0.95) as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn p99(&self) -> Duration {
let mut sorted = self.times.clone();
sorted.sort();
let idx = ((sorted.len() as f64) * 0.99) as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn min(&self) -> Duration {
*self.times.iter().min().unwrap()
}
fn max(&self) -> Duration {
*self.times.iter().max().unwrap()
}
}
struct GrepBench<'a> {
files: &'a [FileItem],
options: GrepSearchOptions,
}
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 {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 50,
mode,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
},
}
}
/// Run a single grep search, return (duration, match_count, files_searched)
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, &parsed, &self.options);
let elapsed = start.elapsed();
(elapsed, result.matches.len(), result.total_files_searched)
}
/// Benchmark a query with multiple iterations
fn bench_query(&self, query: &str, iterations: usize) -> (BenchStats, usize, usize) {
let mut stats = BenchStats::new();
let mut last_matches = 0;
let mut last_files_searched = 0;
for _ in 0..iterations {
let (elapsed, matches, files_searched) = self.run_once(query);
stats.push(elapsed);
last_matches = matches;
last_files_searched = files_searched;
}
(stats, last_matches, last_files_searched)
}
}
fn fmt_dur(d: Duration) -> String {
let us = d.as_micros();
if us > 1_000_000 {
format!("{:.2}s", d.as_secs_f64())
} else if us > 1000 {
format!("{:.2}ms", us as f64 / 1000.0)
} else {
format!("{}us", us)
}
}
fn print_row(name: &str, stats: &BenchStats, matches: usize, files_searched: usize, iters: usize) {
eprintln!(
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
name,
fmt_dur(stats.mean()),
fmt_dur(stats.median()),
fmt_dur(stats.p95()),
fmt_dur(stats.p99()),
fmt_dur(stats.min()),
fmt_dur(stats.max()),
matches,
files_searched,
iters,
);
}
fn print_header() {
eprintln!(
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
"Name", "Mean", "Median", "P95", "P99", "Min", "Max", "Match", "Files", "Iter"
);
eprintln!(
" {:-<24}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<6}-+-{:-<6}-+-{:-<4}",
"", "", "", "", "", "", "", "", "", ""
);
}
fn main() {
// Parse args
let args: Vec<String> = std::env::args().collect();
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
args.get(idx + 1)
.map(|s| s.as_str())
.unwrap_or("./big-repo")
} else {
"./big-repo"
};
let repo = std::path::PathBuf::from(repo_path);
if !repo.exists() {
eprintln!("Repository not found at: {}", repo_path);
eprintln!("Usage: grep_profiler [--path /path/to/large/repo]");
std::process::exit(1);
}
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
eprintln!("=== FFF Live Grep Profiler ===");
eprintln!("Repository: {:?}", canonical);
// Direct file loading (no background thread)
eprintln!("\n[1/7] Loading files...");
let load_start = Instant::now();
let files = load_files(&canonical);
let load_time = load_start.elapsed();
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let large_files = files.iter().filter(|f| f.size > 10 * 1024 * 1024).count();
eprintln!(
" Loaded {} files in {:.2}s ({} non-binary, {} >10MB skipped)\n",
files.len(),
load_time.as_secs_f64(),
non_binary,
large_files,
);
let bench = GrepBench::new(&files);
eprintln!("[2/7] Cold cache benchmarks (first search, mmap not yet loaded)");
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
print_header();
let cold_queries: Vec<(&str, &str)> = vec![
("cold_common_2char", "if"),
("cold_common_word", "return"),
("cold_specific_func", "mutex_lock"),
("cold_struct_name", "inode_operations"),
("cold_define", "MODULE_LICENSE"),
("cold_rare_string", "phylink_ethtool"),
("cold_path_filter", "printk *.c"),
("cold_long_query", "static int __init"),
];
for (name, query) in &cold_queries {
// Re-load files to get fresh FileItems with no cached mmaps
let fresh_files = load_files(&canonical);
let fresh_bench = GrepBench::new(&fresh_files);
let (stats, matches, files_searched) = fresh_bench.bench_query(query, 1);
print_row(name, &stats, matches, files_searched, 1);
}
eprintln!("\n[3/7] Warm cache benchmarks (plain text, mmap cache populated)");
eprintln!(" Running 3 warmup iterations, then measuring.\n");
print_header();
let warm_queries: Vec<(&str, &str, usize)> = vec![
("warm_2char", "if", 10),
("warm_common_word", "return", 10),
("warm_function_call", "mutex_lock", 15),
("warm_struct_name", "inode_operations", 15),
("warm_define", "MODULE_LICENSE", 15),
("warm_rare_string", "phylink_ethtool", 20),
("warm_include", "#include", 10),
("warm_comment", "TODO", 15),
("warm_type_decl", "struct file", 15),
("warm_error_path", "err = -EINVAL", 15),
("warm_long_pattern", "static int __init", 15),
("warm_very_common", "int", 10),
("warm_single_char", "x", 10),
("warm_path_constraint", "printk *.c", 15),
("warm_dir_constraint", "mutex /kernel/", 15),
];
// Warmup pass - populate mmap cache
for (_, query, _) in &warm_queries {
for _ in 0..3 {
bench.run_once(query);
}
}
for (name, query, iters) in &warm_queries {
let (stats, matches, files_searched) = bench.bench_query(query, *iters);
print_row(name, &stats, matches, files_searched, *iters);
}
// ── 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![
(
"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"]),
("inode", vec!["i", "in", "ino", "inod", "inode"]),
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
];
for (name, sequence) in &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) = bench.run_once(prefix);
eprintln!(
" {:>16} | {:>8} | {:>6} | {:>6}",
format!("\"{}\"", prefix),
fmt_dur(elapsed),
matches,
files_searched,
);
}
eprintln!();
}
eprintln!("[7/7] Pagination benchmark");
eprintln!(" Testing page_offset performance for common query.\n");
let pagination_query = "return";
eprintln!(" Query: \"{}\"", pagination_query);
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
"Page", "File offset", "Latency", "Matches", "Next offset"
);
eprintln!(
" {:-<6}-+-{:-<12}-+-{:-<8}-+-{:-<6}-+-{:-<12}",
"", "", "", "", ""
);
let mut file_offset = 0usize;
for page in 0..10 {
let parsed = parse_grep_query(pagination_query);
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset,
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, &parsed, &opts);
let elapsed = start.elapsed();
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
page,
file_offset,
fmt_dur(elapsed),
result.matches.len(),
result.next_file_offset,
);
if result.next_file_offset == 0 || result.matches.is_empty() {
eprintln!(" (no more results)");
break;
}
file_offset = result.next_file_offset;
}
eprintln!("\n=== Summary ===");
let mmap_count = files.iter().filter(|f| f.get_mmap().is_some()).count();
eprintln!(" Files with cached mmap: {}", mmap_count);
eprintln!(" Total indexed files: {}", files.len());
eprintln!(" Non-binary files: {}", non_binary);
eprintln!(" Files > 10MB (skipped): {}", large_files);
std::thread::sleep(Duration::from_millis(100));
eprintln!("\nDone. For perf profiling:");
eprintln!(" perf record -g --call-graph dwarf -F 999 ./target/release/grep_profiler");
eprintln!(" perf report --no-children");
}
+496
View File
@@ -0,0 +1,496 @@
use fff::FFFQuery;
use fff::FileItem;
/// FFF vs ripgrep comparison benchmark
///
/// Demonstrates why a persistent in-process search engine (fff) is fundamentally
/// faster than shelling out to ripgrep on every keystroke (telescope/fzf-lua).
///
/// Each query is run N iterations to show the real-world advantage:
/// - fff: pre-indexed files + cached mmaps = near-zero overhead per search
/// - rg: fork/exec + directory traversal + gitignore parsing + file opens per invocation
///
/// Sections:
/// 1. Raw engine speed — fff count-only vs rg --count-matches (N iterations)
/// 2. Full results — fff collect-all vs rg full line output (N iterations)
/// 3. First-page — fff paginated (50 results) vs rg telescope-style
/// (spawn, stream 50 lines, kill) — the real UI scenario (N iterations)
///
/// The rg commands use telescope's default vimgrep_arguments:
/// rg --color=never --no-heading --with-filename --line-number --column --smart-case
///
/// Usage:
/// cargo build --release --bin grep_vs_rg
/// ./target/release/grep_vs_rg [--path /path/to/repo] [--iters 5]
use fff::grep::{GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
/// Number of times each query is repeated (overridable with --iters).
const DEFAULT_ITERS: usize = 5;
fn load_files(base_path: &Path) -> Vec<FileItem> {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(base_path)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
.ignore(true)
.follow_links(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.for_each(|entry| {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
size,
0,
None,
is_binary,
));
});
files
}
fn detect_binary(path: &Path, size: u64) -> bool {
if size == 0 {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = std::io::BufReader::with_capacity(1024, file);
let mut buf = [0u8; 512];
let n = reader.read(&mut buf).unwrap_or(0);
buf[..n].contains(&0)
}
/// Telescope's default vimgrep_arguments applied to any rg command.
/// Also limits rg's thread count to match rayon's pool (fair comparison).
fn apply_telescope_args(cmd: &mut Command, threads: usize) {
cmd.arg("--color=never")
.arg("--no-heading")
.arg("--with-filename")
.arg("--line-number")
.arg("--column")
.arg("--smart-case")
.arg("--fixed-strings")
.arg("--max-filesize")
.arg("10M")
.arg("--threads")
.arg(threads.to_string());
}
/// Run ripgrep counting matches via --count-matches.
fn run_rg_count(
repo_path: &Path,
pattern: &str,
case_insensitive: bool,
threads: usize,
) -> (usize, Duration) {
let start = Instant::now();
let mut cmd = Command::new("rg");
cmd.arg("--count-matches").arg("--no-filename");
apply_telescope_args(&mut cmd, threads);
if case_insensitive {
cmd.arg("--ignore-case");
}
cmd.arg(pattern).current_dir(repo_path);
let output = cmd.output().expect("Failed to run rg");
let elapsed = start.elapsed();
let stdout = String::from_utf8_lossy(&output.stdout);
let count: usize = stdout
.lines()
.filter_map(|l| l.trim().parse::<usize>().ok())
.sum();
(count, elapsed)
}
/// Run ripgrep collecting full line output.
fn run_rg_lines(
repo_path: &Path,
pattern: &str,
case_insensitive: bool,
threads: usize,
) -> (usize, Duration) {
let start = Instant::now();
let mut cmd = Command::new("rg");
apply_telescope_args(&mut cmd, threads);
if case_insensitive {
cmd.arg("--ignore-case");
}
cmd.arg(pattern).current_dir(repo_path);
let output = cmd.output().expect("Failed to run rg");
let elapsed = start.elapsed();
let count = bytecount(&output.stdout, b'\n');
(count, elapsed)
}
/// Run ripgrep the way telescope/fzf-lua actually do it: spawn rg as a
/// streaming subprocess, read stdout line-by-line, and kill the process
/// after `limit` lines. This is the realistic "first page" scenario.
fn run_rg_page(
repo_path: &Path,
pattern: &str,
case_insensitive: bool,
limit: usize,
threads: usize,
) -> (usize, Duration) {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
let start = Instant::now();
let mut rg_cmd = Command::new("rg");
apply_telescope_args(&mut rg_cmd, threads);
if case_insensitive {
rg_cmd.arg("--ignore-case");
}
rg_cmd
.arg(pattern)
.current_dir(repo_path)
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = rg_cmd.spawn().expect("Failed to spawn rg");
let stdout = child.stdout.take().expect("Failed to get rg stdout");
let reader = BufReader::new(stdout);
let mut count = 0;
for _line in reader.lines() {
if _line.is_err() {
break;
}
count += 1;
if count >= limit {
break;
}
}
// Kill rg immediately — this is what telescope does when the picker
// closes or the query changes (plenary.job:shutdown).
let _ = child.kill();
let _ = child.wait();
let elapsed = start.elapsed();
(count, elapsed)
}
fn bytecount(bytes: &[u8], needle: u8) -> usize {
bytes.iter().filter(|&&b| b == needle).count()
}
/// fff full: collects all GrepMatch structs (what the UI uses).
fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
let parsed = parse_grep_query(query);
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: usize::MAX,
smart_case: true,
file_offset: 0,
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, &parsed, &options);
let elapsed = start.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).
fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
let parsed = parse_grep_query(query);
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
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, &parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
#[allow(dead_code)]
struct IterStats {
min: Duration,
avg: Duration,
count: usize,
}
fn run_n<F: Fn() -> (usize, Duration)>(f: F, n: usize) -> IterStats {
let mut times = Vec::with_capacity(n);
let mut count = 0;
for _ in 0..n {
let (c, d) = f();
count = c;
times.push(d);
}
times.sort();
let min = times[0];
let avg = times.iter().sum::<Duration>() / n as u32;
IterStats { min, avg, count }
}
fn fmt_dur(d: Duration) -> String {
let us = d.as_micros();
if us > 1_000_000 {
format!("{:.2}s", d.as_secs_f64())
} else if us > 1000 {
format!("{:.1}ms", us as f64 / 1000.0)
} else {
format!("{}us", us)
}
}
fn ratio_str(a: Duration, b: Duration) -> String {
if a.is_zero() || b.is_zero() {
return "-".to_string();
}
let r = b.as_secs_f64() / a.as_secs_f64();
format!("{:.1}x", r)
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
args.get(idx + 1)
.map(|s| s.as_str())
.unwrap_or("./big-repo")
} else {
"./big-repo"
};
let iters = if let Some(idx) = args.iter().position(|a| a == "--iters") {
args.get(idx + 1)
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_ITERS)
} else {
DEFAULT_ITERS
};
let repo = std::path::PathBuf::from(repo_path);
if !repo.exists() {
eprintln!("Repository not found at: {}", repo_path);
std::process::exit(1);
}
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let rg_version = Command::new("rg")
.arg("--version")
.output()
.expect("ripgrep (rg) not found in PATH");
let rg_ver = String::from_utf8_lossy(&rg_version.stdout);
// Match rg's thread count to rayon's (both default to logical CPU count).
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
eprintln!("=== FFF vs ripgrep (telescope-style) ===");
eprintln!("Repo: {:?}", canonical);
eprintln!("rg: {}", rg_ver.lines().next().unwrap_or("?"));
eprintln!("Threads: {} (rg -j{} = rayon default)", threads, threads);
eprintln!("Iterations: {} per query", iters);
eprintln!();
eprintln!("[1/5] Indexing files...");
let files = load_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary).count();
eprintln!(" {} files ({} searchable)\n", files.len(), non_binary);
eprintln!("[2/5] Warming caches (fff mmap + OS page cache)...");
for q in &["return", "mutex", "struct", "include", "if", "int"] {
let _ = run_fff_page(&files, q);
let _ = run_rg_count(&canonical, q, true, threads);
}
eprintln!(" mmap cache: warmed\n");
// (name, query, case_insensitive_for_rg)
let queries: Vec<(&str, &str, bool)> = vec![
("single_char", "x", true),
("short_common", "if", true),
("very_common", "int", true),
("common_keyword", "return", true),
("preprocessor", "#include", true),
("function_call", "mutex_lock", true),
("multi_word", "static int __init", true),
("type_decl", "struct file", true),
("macro_define", "MODULE_LICENSE", false),
("kernel_api", "EXPORT_SYMBOL", false),
("error_path", "err = -EINVAL", false),
("comment_tag", "TODO", false),
("struct_name", "inode_operations", true),
("rare_symbol", "phylink_ethtool", true),
("long_literal", "This program is free software", true),
];
eprintln!(
"\n[4/5] Full results: fff (collect all) vs rg (full line output) ({} iters, showing min)\n",
iters
);
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"Query", "fff min", "count", "rg min", "count", "fff/rg"
);
eprintln!(
" {:-<22}-+-{:-<9}-{:-<10}-+-{:-<9}-{:-<10}-+-{:-<7}",
"", "", "", "", "", ""
);
let mut fff_full_total = Duration::ZERO;
let mut rg_full_total = Duration::ZERO;
for (name, query, ci) in &queries {
let q = *query;
let ci = *ci;
let fs = run_n(|| run_fff_full(&files, q), iters);
let rs = run_n(|| run_rg_lines(&canonical, q, ci, threads), iters);
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
name,
fmt_dur(fs.min),
fs.count,
fmt_dur(rs.min),
rs.count,
ratio_str(fs.min, rs.min),
);
fff_full_total += fs.min;
rg_full_total += rs.min;
}
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"TOTAL",
fmt_dur(fff_full_total),
"",
fmt_dur(rg_full_total),
"",
ratio_str(fff_full_total, rg_full_total),
);
eprintln!(
"\n[5/5] First-page latency — the real UI scenario ({} iters, showing min)",
iters
);
eprintln!(" fff: paginated search (50 matches) from warm mmap cache");
eprintln!(" rg: telescope-style (spawn, stream 50 lines, kill) — per-keystroke cost\n");
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"Query", "fff min", "matches", "rg min", "matches", "fff/rg"
);
eprintln!(
" {:-<22}-+-{:-<9}-{:-<10}-+-{:-<9}-{:-<10}-+-{:-<7}",
"", "", "", "", "", ""
);
let mut fff_page_total = Duration::ZERO;
let mut rg_page_total = Duration::ZERO;
for (name, query, ci) in &queries {
let q = *query;
let ci = *ci;
let fs = run_n(|| run_fff_page(&files, q), iters);
let rs = run_n(|| run_rg_page(&canonical, q, ci, 50, threads), iters);
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
name,
fmt_dur(fs.min),
fs.count,
fmt_dur(rs.min),
rs.count,
ratio_str(fs.min, rs.min),
);
fff_page_total += fs.min;
rg_page_total += rs.min;
}
eprintln!(
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
"TOTAL",
fmt_dur(fff_page_total),
"",
fmt_dur(rg_page_total),
"",
ratio_str(fff_page_total, rg_page_total),
);
eprintln!(
"\n=== Summary (total min across all queries, {} iterations) ===\n",
iters
);
eprintln!(
" {:>25} | {:>12} | {:>12} | {:>7}",
"", "fff", "rg", "speedup"
);
eprintln!(" {:->25}-+-{:->12}-+-{:->12}-+-{:->7}", "", "", "", "");
eprintln!(
" {:>25} | {:>12} | {:>12} | {:>7}",
"full results (collect)",
fmt_dur(fff_full_total),
fmt_dur(rg_full_total),
ratio_str(fff_full_total, rg_full_total),
);
eprintln!(
" {:>25} | {:>12} | {:>12} | {:>7}",
"first-page (UI latency)",
fmt_dur(fff_page_total),
fmt_dur(rg_page_total),
ratio_str(fff_page_total, rg_page_total),
);
eprintln!();
eprintln!(" Note: rg cost includes fork/exec + directory traversal + gitignore parsing");
eprintln!(" on EVERY invocation (= every keystroke in telescope/fzf-lua).");
eprintln!(" fff pays this cost once at startup, then searches from warm cached mmaps.");
eprintln!();
}
@@ -1,5 +1,7 @@
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
@@ -59,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,
@@ -81,20 +84,24 @@ 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,
fff_nvim::file_picker::FuzzySearchOptions {
max_results: 50 + (i % 50),
&parsed,
None,
FuzzySearchOptions {
max_threads: 1 + (i % 4),
current_file: None,
reverse_order: false,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 50 + (i % 50),
},
},
);
(search_result.items.len(), search_result.total_matched)
@@ -171,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()
{
@@ -194,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);
@@ -210,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",
];
@@ -221,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",
];
@@ -1,9 +1,12 @@
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::FilePicker;
use fff::file_picker::{FFFMode, FilePicker};
use fff::{
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<fff_nvim::types::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 {
@@ -79,21 +70,31 @@ fn main() {
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
let canonical_path =
fff::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![
@@ -119,20 +120,24 @@ fn main() {
for (name, query, iterations) in test_queries {
let start = Instant::now();
let mut match_count = 0;
let parser = QueryParser::default();
for _ in 0..iterations {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
fff_nvim::file_picker::FuzzySearchOptions {
max_results: 100,
&parsed,
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
reverse_order: false,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
@@ -1,6 +1,8 @@
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{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};
@@ -76,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...");
@@ -101,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...");
@@ -127,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),
}
@@ -138,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() {
@@ -150,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
}
};
@@ -196,21 +193,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let max_threads = 1 + (search_count % 8); // Vary thread count
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,
fff_nvim::file_picker::FuzzySearchOptions {
max_results,
&parsed,
None,
FuzzySearchOptions {
max_threads,
current_file: None,
reverse_order: false,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: max_results,
},
},
);
let duration = search_start.elapsed();
@@ -2,33 +2,16 @@
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
use fff_nvim::{FILE_PICKER, FRECENCY, file_picker::FilePicker, git::format_git_status};
use fff::file_picker::FilePicker;
use fff::git::format_git_status;
use fff::{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() {
let _ = 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 {
@@ -41,28 +24,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() {
@@ -94,8 +88,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 {
@@ -109,13 +103,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!(
@@ -134,8 +127,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 {
@@ -154,20 +147,24 @@ 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",
fff_nvim::file_picker::FuzzySearchOptions {
max_results: 5,
&parsed,
None,
FuzzySearchOptions {
max_threads: 2,
current_file: None,
reverse_order: false,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 5,
},
},
);
@@ -190,13 +187,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(())
}
+37
View File
@@ -0,0 +1,37 @@
//! Error handling for fff-nvim
//!
//! This module provides utilities for converting fff errors to mlua errors.
use fff::Error as CoreError;
/// Convert a fff::Error to mlua::Error
///
/// This function is used because we can't implement From<CoreError> for mlua::Error
/// due to Rust's orphan rules (both types are foreign to this crate).
pub fn to_lua_error(err: CoreError) -> mlua::Error {
let string_value = err.to_string();
::tracing::error!(string_value);
mlua::Error::RuntimeError(string_value)
}
/// Extension trait for Result<T, fff::Error> to convert to LuaResult<T>
pub trait IntoLuaResult<T> {
fn into_lua_result(self) -> mlua::Result<T>;
}
impl<T> IntoLuaResult<T> for Result<T, CoreError> {
fn into_lua_result(self) -> mlua::Result<T> {
self.map_err(to_lua_error)
}
}
/// Extension trait for Result<T, PoisonError> to convert to Result<T, CoreError>
pub trait IntoCoreError<T> {
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError>;
}
impl<T, G> IntoCoreError<T> for Result<T, std::sync::PoisonError<G>> {
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError> {
self.map_err(|_| err)
}
}
+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))
}
+912
View File
@@ -0,0 +1,912 @@
use crate::path_shortening::shorten_path_with_cache;
use error::{IntoCoreError, IntoLuaResult};
use fff::file_picker::FilePicker;
use fff::frecency::FrecencyTracker;
use fff::path_utils::expand_tilde;
use fff::query_tracker::QueryTracker;
use fff::{
DbHealthChecker, Error, FFFMode, FileSearchConfig, 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;
#[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),
) -> LuaResult<bool> {
let mut frecency = FRECENCY
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
if frecency.is_some() {
*frecency = None;
}
*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
let _ = FrecencyTracker::spawn_gc(Arc::clone(&FRECENCY), frecency_db_path, use_unsafe_no_lock);
let mut query_tracker = QUERY_TRACKER
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
if query_tracker.is_some() {
*query_tracker = None;
}
*query_tracker =
Some(QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?);
tracing::info!("Query tracker database initialized at {}", history_db_path);
Ok(true)
}
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
let mut frecency = FRECENCY
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
*frecency = None;
Ok(true)
}
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
let mut query_tracker = QUERY_TRACKER
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
*query_tracker = None;
Ok(true)
}
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
{
let guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if guard.is_some() {
return Ok(false);
}
}
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> {
// 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.
}
// 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(())
}
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
let path = std::path::PathBuf::from(&new_path);
if !path.exists() {
return Err(LuaError::RuntimeError(format!(
"Path does not exist: {}",
new_path
)));
}
let canonical_path = fff::path_utils::canonicalize(&path).map_err(|e| {
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) {
::tracing::error!(
?e,
?canonical_path,
"Failed to index directory after changing"
);
} else {
::tracing::info!(?canonical_path, "Successfully reindexed directory");
}
});
Ok(())
}
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let picker = file_picker
.as_mut()
.ok_or(Error::FilePickerMissing)
.into_lua_result()?;
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
::tracing::info!("scan_files trigger_rescan completed");
Ok(())
}
#[allow(clippy::type_complexity)]
pub fn fuzzy_search_files(
lua: &Lua,
(
query,
max_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
page_index,
page_size,
): (
String,
usize,
Option<String>,
i32,
Option<u32>,
Option<usize>,
Option<usize>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref picker) = *file_picker_guard else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let base_path = picker.base_path();
let min_combo_count = min_combo_count.unwrap_or(3);
let query_tracker_guard = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
if query_tracker_guard.as_ref().is_none() {
tracing::warn!("Query tracker not initialized");
}
tracing::debug!(
?base_path,
?query,
?min_combo_count,
?page_index,
?page_size,
"Fuzzy search parameters"
);
let parser = QueryParser::new(FileSearchConfig);
let parsed = parser.parse(&query);
let files = picker.get_files();
let results = FilePicker::fuzzy_search(
files,
&parsed,
query_tracker_guard.as_ref(),
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
project_path: Some(picker.base_path()),
combo_boost_score_multiplier,
min_combo_count,
pagination: PaginationArgs {
offset: page_index.unwrap_or(0),
limit: page_size.unwrap_or(0),
},
},
);
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)
}
#[allow(clippy::type_complexity)]
pub fn live_grep(
lua: &Lua,
(
query,
file_offset,
page_size,
max_file_size,
max_matches_per_file,
smart_case,
grep_mode,
time_budget_ms,
): (
String,
Option<usize>,
Option<usize>,
Option<u64>,
Option<usize>,
Option<bool>,
Option<String>,
Option<u64>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref picker) = *file_picker_guard else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let parsed = fff::grep::parse_grep_query(&query);
let mode = match grep_mode.as_deref() {
Some("regex") => fff::GrepMode::Regex,
Some("fuzzy") => fff::GrepMode::Fuzzy,
_ => fff::GrepMode::PlainText, // "plain" or nil or unknown
};
let options = fff::GrepSearchOptions {
max_file_size: max_file_size.unwrap_or(10 * 1024 * 1024),
max_matches_per_file: max_matches_per_file.unwrap_or(200),
smart_case: smart_case.unwrap_or(true),
file_offset: file_offset.unwrap_or(0),
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::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);
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
let frecency_guard = FRECENCY
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let Some(ref frecency) = *frecency_guard else {
return Ok(false);
};
frecency
.track_access(file_path.as_path())
.into_lua_result()?;
drop(frecency_guard);
// Quick lock to update single file's frecency score in picker
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref mut picker) = *file_picker else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let frecency_guard = FRECENCY
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let Some(ref frecency) = *frecency_guard else {
return Ok(false);
};
picker
.update_single_file_frecency(&file_path, frecency)
.into_lua_result()?;
Ok(true)
}
pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
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()?;
let progress = picker.get_scan_progress();
let table = lua.create_table()?;
table.set("scanned_files_count", progress.scanned_files_count)?;
table.set("is_scanning", progress.is_scanning)?;
Ok(LuaValue::Table(table))
}
pub fn is_scanning(_: &Lua, _: ()) -> 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()?;
Ok(picker.is_scan_active())
}
pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(None);
};
Ok(picker.git_root().map(|p| p.to_string_lossy().into_owned()))
}
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
}
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
let frecency_guard = FRECENCY
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let Some(ref frecency) = *frecency_guard else {
return Ok(false);
};
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref mut picker) = *file_picker else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
picker
.update_single_file_frecency(&file_path, frecency)
.into_lua_result()?;
Ok(true)
}
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref mut picker) = *file_picker else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
picker.stop_background_monitor();
Ok(true)
}
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if let Some(picker) = file_picker.take() {
drop(picker);
::tracing::info!("FilePicker cleanup completed");
Ok(true)
} else {
Ok(false)
}
}
pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
Ok(true)
}
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
// Get the project path before spawning thread
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(false);
};
picker.base_path().to_path_buf()
};
// Canonicalize the file path before spawning thread
let file_path = match fff::path_utils::canonicalize(&file_path) {
Ok(path) => path,
Err(e) => {
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
return Ok(false);
}
};
// 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()
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
{
tracing::error!(
query = %query,
file = %file_path.display(),
error = ?e,
"Failed to track query completion"
);
}
});
Ok(true)
}
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(None);
};
picker.base_path().to_path_buf()
};
let query_tracker = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let Some(ref tracker) = *query_tracker else {
return Ok(None);
};
tracker
.get_historical_query(&project_path, offset)
.into_lua_result()
}
pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(false);
};
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()
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
{
tracing::error!(
query = %query,
error = ?e,
"Failed to track grep query"
);
}
});
Ok(true)
}
pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
let project_path = {
let file_picker = FILE_PICKER
.read()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
let Some(ref picker) = *file_picker else {
return Ok(None);
};
picker.base_path().to_path_buf()
};
let query_tracker = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let Some(ref tracker) = *query_tracker else {
return Ok(None);
};
tracker
.get_historical_grep_query(&project_path, offset)
.into_lua_result()
}
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
// 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 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);
}
std::thread::sleep(sleep_duration);
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
}
::tracing::debug!(
"wait_for_initial_scan completed in {:?}",
start_time.elapsed()
);
Ok(true)
}
pub fn init_tracing(
_: &Lua,
(log_file_path, log_level): (String, Option<String>),
) -> LuaResult<String> {
crate::log::init_tracing(&log_file_path, log_level.as_deref())
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
}
/// Returns health check information including version, git2 status, and repository detection
pub fn health_check(lua: &Lua, test_path: Option<String>) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("version", env!("CARGO_PKG_VERSION"))?;
let test_path = test_path
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let git_info = lua.create_table()?;
let git_version = git2::Version::get();
let (major, minor, rev) = git_version.libgit2_version();
let libgit2_version_str = format!("{}.{}.{}", major, minor, rev);
match git2::Repository::discover(&test_path) {
Ok(repo) => {
git_info.set("available", true)?;
git_info.set("repository_found", true)?;
if let Some(workdir) = repo.workdir() {
git_info.set("workdir", workdir.to_string_lossy().to_string())?;
}
// Get git2 version info
git_info.set("libgit2_version", libgit2_version_str.clone())?;
}
Err(e) => {
git_info.set("available", true)?;
git_info.set("repository_found", false)?;
git_info.set("error", e.message().to_string())?;
git_info.set("libgit2_version", libgit2_version_str)?;
}
}
table.set("git", git_info)?;
// Check file picker status
let picker_info = lua.create_table()?;
match FILE_PICKER.read() {
Ok(guard) => {
if let Some(ref picker) = *guard {
picker_info.set("initialized", true)?;
picker_info.set(
"base_path",
picker.base_path().to_string_lossy().to_string(),
)?;
picker_info.set("is_scanning", picker.is_scan_active())?;
let progress = picker.get_scan_progress();
picker_info.set("indexed_files", progress.scanned_files_count)?;
} else {
picker_info.set("initialized", false)?;
}
}
Err(_) => {
picker_info.set("initialized", false)?;
picker_info.set("error", "Failed to acquire file picker lock")?;
}
}
table.set("file_picker", picker_info)?;
let frecency_info = lua.create_table()?;
match FRECENCY.read() {
Ok(guard) => {
frecency_info.set("initialized", guard.is_some())?;
if let Some(ref frecency) = *guard {
match frecency.get_health() {
Ok(health) => {
let healthcheck_table = lua.create_table()?;
healthcheck_table.set("path", health.path)?;
healthcheck_table.set("disk_size", health.disk_size)?;
for (name, count) in health.entry_counts {
healthcheck_table.set(name, count)?;
}
frecency_info.set("db_healthcheck", healthcheck_table)?;
}
Err(e) => {
frecency_info.set("db_healthcheck_error", e.to_string())?;
}
}
}
}
Err(_) => {
frecency_info.set("initialized", false)?;
frecency_info.set("error", "Failed to acquire frecency lock")?;
}
}
table.set("frecency", frecency_info)?;
let query_tracker_info = lua.create_table()?;
match QUERY_TRACKER.read() {
Ok(guard) => {
query_tracker_info.set("initialized", guard.is_some())?;
if let Some(ref query_history) = *guard {
match query_history.get_health() {
Ok(health) => {
let healthcheck_table = lua.create_table()?;
healthcheck_table.set("path", health.path)?;
healthcheck_table.set("disk_size", health.disk_size)?;
for (name, count) in health.entry_counts {
healthcheck_table.set(name, count)?;
}
query_tracker_info.set("db_healthcheck", healthcheck_table)?;
}
Err(e) => {
query_tracker_info.set("db_healthcheck_error", e.to_string())?;
}
}
}
}
Err(_) => {
query_tracker_info.set("initialized", false)?;
query_tracker_info.set("error", "Failed to acquire query tracker lock")?;
}
}
table.set("query_tracker", query_tracker_info)?;
Ok(LuaValue::Table(table))
}
pub fn shorten_path(
_: &Lua,
(path, max_size, strategy): (String, usize, Option<mlua::Value>),
) -> LuaResult<String> {
let strategy = strategy
.map(|v| -> LuaResult<PathShortenStrategy> {
match v {
mlua::Value::String(ref s) => {
let name = s
.to_str()
.map(|s| s.to_owned())
.unwrap_or_else(|_| "middle_number".to_string());
Ok(PathShortenStrategy::from_name(&name))
}
_ => Ok(PathShortenStrategy::default()),
}
})
.transpose()?
.unwrap_or_default();
shorten_path_with_cache(strategy, max_size, Path::new(&path)).map_err(LuaError::RuntimeError)
}
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
let exports = lua.create_table()?;
exports.set("init_db", lua.create_function(init_db)?)?;
exports.set(
"destroy_frecency_db",
lua.create_function(destroy_frecency_db)?,
)?;
exports.set("init_file_picker", lua.create_function(init_file_picker)?)?;
exports.set(
"restart_index_in_path",
lua.create_function(restart_index_in_path)?,
)?;
exports.set("scan_files", lua.create_function(scan_files)?)?;
exports.set(
"fuzzy_search_files",
lua.create_function(fuzzy_search_files)?,
)?;
exports.set("live_grep", lua.create_function(live_grep)?)?;
exports.set("track_access", lua.create_function(track_access)?)?;
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
exports.set(
"refresh_git_status",
lua.create_function(refresh_git_status)?,
)?;
exports.set("get_git_root", lua.create_function(get_git_root)?)?;
exports.set(
"stop_background_monitor",
lua.create_function(stop_background_monitor)?,
)?;
exports.set("init_tracing", lua.create_function(init_tracing)?)?;
exports.set(
"wait_for_initial_scan",
lua.create_function(wait_for_initial_scan)?,
)?;
exports.set(
"cleanup_file_picker",
lua.create_function(cleanup_file_picker)?,
)?;
exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?;
exports.set(
"track_query_completion",
lua.create_function(track_query_completion)?,
)?;
exports.set(
"get_historical_query",
lua.create_function(get_historical_query)?,
)?;
exports.set("track_grep_query", lua.create_function(track_grep_query)?)?;
exports.set(
"get_historical_grep_query",
lua.create_function(get_historical_grep_query)?,
)?;
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)
}
// https://github.com/mlua-rs/mlua/issues/318
#[mlua::lua_module(skip_memory_check)]
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
// Install panic hook IMMEDIATELY on module load
// This ensures any panics are logged even if init_tracing is never called
crate::log::install_panic_hook();
create_exports(lua)
}
+3
View File
@@ -0,0 +1,3 @@
//! Logging setup for fff-nvim — delegates to the shared fff-core::log utilities.
pub use fff::log::{init_tracing, install_panic_hook};
+177
View File
@@ -0,0 +1,177 @@
use fff::git::format_git_status;
use fff::{FileItem, GrepResult, Location, Score, SearchResult};
use mlua::prelude::*;
pub struct SearchResultLua<'a> {
inner: SearchResult<'a>,
}
impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
fn from(inner: SearchResult<'a>) -> Self {
Self { inner }
}
}
pub struct GrepResultLua<'a> {
inner: GrepResult<'a>,
}
impl<'a> From<GrepResult<'a>> for GrepResultLua<'a> {
fn from(inner: GrepResult<'a>) -> Self {
Self { inner }
}
}
struct LuaPosition((i32, i32));
impl IntoLua for LuaPosition {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("line", self.0.0)?;
table.set("col", self.0.1)?;
Ok(LuaValue::Table(table))
}
}
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("path", item.path.to_string_lossy().to_string())?;
table.set("relative_path", item.relative_path.clone())?;
table.set("name", item.file_name.clone())?;
table.set("size", item.size)?;
table.set("modified", item.modified)?;
table.set("access_frecency_score", item.access_frecency_score)?;
table.set(
"modification_frecency_score",
item.modification_frecency_score,
)?;
table.set("total_frecency_score", item.total_frecency_score)?;
table.set("git_status", format_git_status(item.git_status))?;
table.set("is_binary", item.is_binary)?;
Ok(LuaValue::Table(table))
}
fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("total", score.total)?;
table.set("base_score", score.base_score)?;
table.set("filename_bonus", score.filename_bonus)?;
table.set("special_filename_bonus", score.special_filename_bonus)?;
table.set("frecency_boost", score.frecency_boost)?;
table.set("distance_penalty", score.distance_penalty)?;
table.set("current_file_penalty", score.current_file_penalty)?;
table.set("combo_match_boost", score.combo_match_boost)?;
table.set("match_type", score.match_type)?;
table.set("exact_match", score.exact_match)?;
Ok(LuaValue::Table(table))
}
impl IntoLua for SearchResultLua<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
// Convert items
let items_table = lua.create_table()?;
for (i, item) in self.inner.items.iter().enumerate() {
items_table.set(i + 1, file_item_into_lua(item, lua)?)?;
}
table.set("items", items_table)?;
// Convert scores
let scores_table = lua.create_table()?;
for (i, score) in self.inner.scores.iter().enumerate() {
scores_table.set(i + 1, score_into_lua(score, lua)?)?;
}
table.set("scores", scores_table)?;
table.set("total_matched", self.inner.total_matched)?;
table.set("total_files", self.inner.total_files)?;
if let Some(location) = &self.inner.location {
let location_table = lua.create_table()?;
match location {
Location::Line(line) => {
location_table.set("line", *line)?;
}
Location::Position { line, col } => {
location_table.set("line", *line)?;
location_table.set("col", *col)?;
}
Location::Range { start, end } => {
location_table.set("start", LuaPosition(*start))?;
location_table.set("end", LuaPosition(*end))?;
}
}
table.set("location", location_table)?;
}
Ok(LuaValue::Table(table))
}
}
impl IntoLua for GrepResultLua<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
// Convert grep match items — each includes file metadata + match metadata
let items_table = lua.create_table()?;
for (i, m) in self.inner.matches.iter().enumerate() {
let item = lua.create_table()?;
// File metadata from the deduplicated files vec
let file = self.inner.files[m.file_index];
item.set("path", file.path.to_string_lossy().to_string())?;
item.set("relative_path", file.relative_path.as_str())?;
item.set("name", file.file_name.as_str())?;
item.set("is_binary", file.is_binary)?;
item.set("git_status", format_git_status(file.git_status))?;
item.set("size", file.size)?;
item.set("modified", file.modified)?;
item.set("total_frecency_score", file.total_frecency_score)?;
item.set("access_frecency_score", file.access_frecency_score)?;
item.set(
"modification_frecency_score",
file.modification_frecency_score,
)?;
// Match metadata
item.set("line_number", m.line_number)?;
item.set("col", m.col)?;
item.set("byte_offset", m.byte_offset)?;
item.set("line_content", m.line_content.as_str())?;
// Match byte ranges within line_content
let ranges = lua.create_table()?;
for (j, &(start, end)) in m.match_byte_offsets.iter().enumerate() {
let range = lua.create_table()?;
range.set(1, start)?;
range.set(2, end)?;
ranges.set(j + 1, range)?;
}
item.set("match_ranges", ranges)?;
// Fuzzy match score (only set in fuzzy grep mode, nil otherwise)
if let Some(score) = m.fuzzy_score {
item.set("fuzzy_score", score)?;
}
items_table.set(i + 1, item)?;
}
table.set("items", items_table)?;
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)?;
table.set("next_file_offset", self.inner.next_file_offset)?;
// Pass regex fallback error to Lua (nil if no error)
if let Some(ref err) = self.inner.regex_fallback_error {
table.set("regex_fallback_error", err.as_str())?;
}
Ok(LuaValue::Table(table))
}
}
+615
View File
@@ -0,0 +1,615 @@
//! Path shortening utilities for display in Neovim UI
//!
//! This module provides functionality to shorten file paths for display
//! in the picker UI with various strategies.
use once_cell::sync::Lazy;
use std::borrow::Cow;
use std::path::{Component, MAIN_SEPARATOR, Path, PathBuf};
use std::sync::RwLock;
#[derive(Clone, Copy, Default)]
pub enum PathShortenStrategy {
#[default]
MiddleNumber,
Middle,
End,
}
struct CacheEntry {
shortened: String,
max_size: usize,
}
struct PathCache {
map: ahash::AHashMap<PathBuf, CacheEntry>,
max_entries: usize,
}
impl PathCache {
fn new(max_entries: usize) -> Self {
Self {
map: ahash::AHashMap::with_capacity(max_entries),
max_entries,
}
}
#[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
if entry.max_size == max_size {
Some(entry.shortened.as_str())
} else {
None
}
})
}
fn insert(&mut self, path: PathBuf, shortened: String, max_size: usize) {
// Simple eviction: clear half the cache when full
if self.map.len() >= self.max_entries {
let keys_to_remove: Vec<_> = self
.map
.keys()
.take(self.max_entries / 2)
.cloned()
.collect();
for key in keys_to_remove {
self.map.remove(&key);
}
}
self.map.insert(
path,
CacheEntry {
shortened,
max_size,
},
);
}
}
// this is the amount of PATHS not entries
const DEFAULT_CACHE_SIZE: usize = 8192;
static PATH_SHORTEN_CACHE: Lazy<RwLock<PathCache>> =
Lazy::new(|| RwLock::new(PathCache::new(DEFAULT_CACHE_SIZE)));
pub fn shorten_path_with_cache(
strategy: PathShortenStrategy,
max_size: usize,
path: &Path,
) -> Result<String, String> {
{
let cache = PATH_SHORTEN_CACHE
.read()
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
if let Some(cached) = cache.get(path, max_size) {
tracing::trace!("Cache hit for path '{}'", path.display());
return Ok(cached.to_string());
}
}
let shortened = strategy.shorten_path(path, max_size);
{
let mut cache = PATH_SHORTEN_CACHE
.write()
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
cache.insert(path.to_path_buf(), shortened.clone(), max_size);
}
Ok(shortened)
}
impl PathShortenStrategy {
/// Parse a strategy from a string name
pub fn from_name(name: &str) -> Self {
match name {
"middle_number" => PathShortenStrategy::MiddleNumber,
"middle" => PathShortenStrategy::Middle,
"end" => PathShortenStrategy::End,
_ => PathShortenStrategy::MiddleNumber,
}
}
}
impl PathShortenStrategy {
pub fn shorten_path(&self, path: &Path, max_size: usize) -> String {
const MIN_SMART_SHORTEN_SIZE: usize = 8;
let sep = MAIN_SEPARATOR;
let path_str = path.to_string_lossy();
if path_str.len() <= max_size {
return path_str.to_string();
}
// If max_size is too small for smart shortening, just truncate
if max_size < MIN_SMART_SHORTEN_SIZE {
return Self::truncate_str(&path_str, max_size);
}
let components: Vec<&str> = path
.components()
.filter_map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect();
if components.is_empty() {
return path_str.to_string();
}
// For single component, just truncate it
if components.len() == 1 {
return Self::truncate_str(components[0], max_size);
}
match self {
PathShortenStrategy::End => {
// Simple truncation from the end
let mut result = String::new();
for (i, component) in components.iter().enumerate() {
let candidate = if i == 0 {
component.to_string()
} else {
format!("{}{}{}", result, sep, component)
};
if candidate.len() <= max_size {
result = candidate;
} else {
break;
}
}
// If even the first component is too long, truncate it
if result.is_empty() && !components.is_empty() {
return components.first().map_or(String::new(), |component| {
let mut component = component.to_string();
component.truncate(max_size);
component
});
}
result
}
PathShortenStrategy::Middle | PathShortenStrategy::MiddleNumber => {
let use_number = matches!(self, PathShortenStrategy::MiddleNumber);
self.shorten_middle(&components, max_size, use_number, sep)
}
}
}
// rust doesn't have an ergonomic way to clone and truncate
fn truncate_str(s: &str, max_len: usize) -> String {
if max_len == 0 {
return String::new();
}
let char_count = s.chars().count();
if char_count <= max_len {
return s.to_string();
}
// Just take the first max_len characters - no ".." suffix
s.chars().take(max_len).collect()
}
fn shorten_middle(
&self,
components: &[&str],
max_size: usize,
use_number: bool,
sep: char,
) -> String {
let total = components.len();
// For 2 components, just show both or truncate to fit
if total <= 2 {
let joined = components.join(&sep.to_string());
if joined.len() <= max_size {
return joined;
}
// Try to keep last intact, truncate first
let last = components[total - 1];
let available_for_first = max_size.saturating_sub(1 + last.len()); // sep + last
if available_for_first > 0 && last.len() < max_size {
let truncated = Self::truncate_str(components[0], available_for_first);
let mut result = String::with_capacity(truncated.len() + 1 + last.len());
result.push_str(&truncated);
result.push(sep);
result.push_str(last);
return result;
}
// Last component alone exceeds max_size, must truncate it
return Self::truncate_str(last, max_size);
}
let first = components[0];
let last = components[total - 1];
let initial_hidden = total - 2;
let ellipsis = Self::make_ellipsis(initial_hidden, use_number);
// Minimum pattern: first/.../last
let min_overhead = 2 + ellipsis.len(); // two separators + ellipsis
let min_content = first.len() + last.len();
if min_content + min_overhead <= max_size {
// We can fit first/.../last, now try to add more components
return self.expand_middle(components, max_size, use_number, sep);
}
// Need to truncate to fit max_size
// Priority: keep last intact if possible, truncate first, then truncate last if needed
let needed_for_last = last.len() + 1 + ellipsis.len() + 1; // sep + ellipsis + sep + last
if needed_for_last <= max_size {
let available_for_first = max_size - needed_for_last;
let truncated_first = Self::truncate_str(first, available_for_first);
let ellipsis = Self::make_ellipsis(initial_hidden, use_number);
// truncated_first + sep + ellipsis + sep + last
let capacity = truncated_first.len() + 1 + ellipsis.len() + 1 + last.len();
let mut result = String::with_capacity(capacity);
result.push_str(&truncated_first);
result.push(sep);
result.push_str(&ellipsis);
result.push(sep);
result.push_str(last);
return result;
}
let needed_for_ellipsis_last = ellipsis.len() + 1 + last.len(); // ellipsis + sep + last
if needed_for_ellipsis_last <= max_size {
let mut result = String::with_capacity(needed_for_ellipsis_last);
result.push_str(&ellipsis);
result.push(sep);
result.push_str(last);
return result;
}
// Can't fit ellipsis + last, just show as much of last as possible
Self::truncate_str(last, max_size)
}
fn expand_middle(
&self,
components: &[&str],
max_size: usize,
use_number: bool,
sep: char,
) -> String {
let total = components.len();
// Start with minimum: first/...or..N../last
let mut left_end = 1; // exclusive index for left components
let mut right_start = total - 1; // inclusive index for right components
// Try to add more components from both sides
loop {
if right_start <= left_end {
break;
}
let mut added = false;
// Try adding from RIGHT first (to show more context near the file)
if right_start > left_end + 1 {
let hidden = right_start - 1 - left_end;
let candidate = Self::build_middle_result(
components,
left_end,
right_start - 1,
hidden,
use_number,
sep,
);
if candidate.len() <= max_size {
right_start -= 1;
added = true;
}
}
// Try adding from LEFT
if left_end < right_start - 1 {
let hidden = right_start - (left_end + 1);
let candidate = Self::build_middle_result(
components,
left_end + 1,
right_start,
hidden,
use_number,
sep,
);
if candidate.len() <= max_size {
left_end += 1;
added = true;
}
}
if !added {
break;
}
}
let hidden = right_start - left_end;
Self::build_middle_result(components, left_end, right_start, hidden, use_number, sep)
}
fn build_middle_result(
components: &[&str],
left_end: usize,
right_start: usize,
hidden_count: usize,
use_number: bool,
sep: char,
) -> String {
let ellipsis = Self::make_ellipsis(hidden_count, use_number);
let left_parts = &components[..left_end];
let right_parts = &components[right_start..];
// Pre-calculate capacity
let left_len: usize = left_parts.iter().map(|s| s.len()).sum();
let right_len: usize = right_parts.iter().map(|s| s.len()).sum();
let left_seps = if left_parts.is_empty() {
0
} else {
left_parts.len() - 1
};
let right_seps = if right_parts.is_empty() {
0
} else {
right_parts.len() - 1
};
// +2 for separators around ellipsis (or +1 if left is empty)
let extra_seps = if left_parts.is_empty() { 1 } else { 2 };
let capacity = left_len + right_len + left_seps + right_seps + ellipsis.len() + extra_seps;
let mut result = String::with_capacity(capacity);
// Build left part
for (i, part) in left_parts.iter().enumerate() {
if i > 0 {
result.push(sep);
}
result.push_str(part);
}
// Add separator before ellipsis (only if left is not empty)
if !left_parts.is_empty() {
result.push(sep);
}
// Add ellipsis
result.push_str(&ellipsis);
// Add separator after ellipsis
result.push(sep);
// Build right part
for (i, part) in right_parts.iter().enumerate() {
if i > 0 {
result.push(sep);
}
result.push_str(part);
}
result
}
fn make_ellipsis(hidden_count: usize, use_number: bool) -> Cow<'static, str> {
match hidden_count {
1 => ".".into(),
2 => "..".into(),
3 if use_number => "...".into(),
n if use_number => format!(".{}.", n).into(),
_ => "...".into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_shorten_strategy_middle() {
// Test with directory paths (not file paths) - this is what Lua passes
let path = Path::new("core_workflow_service/db/model/parts/ai_extracted");
// With 25 chars, first component must be truncated
// "core_workflow_service" is 21 chars, so we need to truncate it
let shortened = PathShortenStrategy::Middle.shorten_path(path, 25);
assert!(
shortened.len() <= 25,
"Result '{}' should be <= 25 chars",
shortened
);
assert!(shortened.contains("..."), "Should contain ellipsis");
assert!(
shortened.ends_with("ai_extracted"),
"Should end with last component"
);
// With 45 chars, can fit more without truncation
let shortened = PathShortenStrategy::Middle.shorten_path(path, 45);
assert!(shortened.len() <= 45);
assert!(shortened.starts_with("core_workflow_service"));
// Shorter path that fits better
let path2 = Path::new("src/components/ui/buttons");
let shortened = PathShortenStrategy::Middle.shorten_path(path2, 20);
assert!(
shortened.len() <= 20,
"Result '{}' should be <= 20 chars",
shortened
);
// Very small max_size - should still produce something reasonable
let shortened = PathShortenStrategy::Middle.shorten_path(path2, 10);
assert!(
shortened.len() <= 10,
"Result '{}' should be <= 10 chars",
shortened
);
}
#[test]
fn test_path_shroten_strategy_middle_number() {
// Test with directory paths (not file paths)
// middle_number uses dots for 1-3 hidden, numbers for 4+
// Path with only 2 hidden segments - should use dots
let path = Path::new("core_workflow_service/graphql/types/parts");
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 40);
assert!(
shortened.len() <= 40,
"Result '{}' should be <= 40 chars",
shortened
);
// With only 2 hidden, should use dots not numbers
assert!(
shortened.contains('.'),
"Should contain dots, got '{}'",
shortened
);
// Path with many segments, small space - should use .N. format when 4+ hidden
let path2 = Path::new("a/b/c/d/e/f/g/h/i/j");
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path2, 12);
assert!(
shortened.len() <= 12,
"Result '{}' should be <= 12 chars",
shortened
);
// With 8 hidden (showing only a and j), should show number
assert!(
shortened.contains('.') && shortened.chars().any(|c| c.is_ascii_digit()),
"Should contain .N. pattern for 4+ hidden, got '{}'",
shortened
);
// Very small max_size
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path2, 5);
assert!(
shortened.len() <= 5,
"Result '{}' should be <= 5 chars",
shortened
);
}
#[test]
fn test_path_shroten_strategy_end() {
let path = Path::new("core_workflow_service/db/model/parts/ai_extracted");
let shortened = PathShortenStrategy::End.shorten_path(path, 25);
assert!(shortened.len() <= 25);
assert!(shortened.starts_with("core_workflow_service"));
// Shorter constraint - truncates first component
let shortened = PathShortenStrategy::End.shorten_path(path, 15);
assert!(
shortened.len() <= 15,
"Result '{}' should be <= 15 chars",
shortened
);
}
#[test]
fn test_shorten_path_caching() {
let path = Path::new("home/user/projects/rust/project/src/components/ui");
// First call should compute and cache
let result1 = shorten_path_with_cache(PathShortenStrategy::MiddleNumber, 25, path).unwrap();
// Second call should hit cache
let result2 = shorten_path_with_cache(PathShortenStrategy::MiddleNumber, 25, path).unwrap();
assert_eq!(result1, result2);
// Different max_size should produce different result (more space = longer result)
let result3 = shorten_path_with_cache(PathShortenStrategy::MiddleNumber, 50, path).unwrap();
assert!(
result3.len() >= result1.len(),
"More space should allow longer result"
);
}
#[test]
fn test_path_always_fits_max_size() {
// Path must ALWAYS fit within max_size - this is a strict requirement
let paths = [
"core_workflow_service/db/model/parts/ai_extracted",
"home/user/projects/rust/project/src",
"a/b/c/d/e/f/g/h",
"very_long_directory_name/another_long_one/and_more",
];
for path_str in paths {
let path = Path::new(path_str);
for max_size in [10, 15, 20, 25, 30, 40, 50] {
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, max_size);
assert!(
shortened.len() <= max_size,
"Path '{}' with max_size {} produced '{}' ({} chars)",
path_str,
max_size,
shortened,
shortened.len()
);
let shortened = PathShortenStrategy::Middle.shorten_path(path, max_size);
assert!(
shortened.len() <= max_size,
"Path '{}' with max_size {} produced '{}' ({} chars)",
path_str,
max_size,
shortened,
shortened.len()
);
}
}
}
#[test]
fn test_small_max_size_simple_truncation() {
// When max_size is very small (< MIN_SMART_SHORTEN_SIZE), should just truncate
let path = Path::new("core_workflow_service/db/model/parts");
// With max_size=6, should just truncate (below threshold)
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 6);
assert_eq!(shortened.len(), 6);
assert_eq!(shortened, "core_w");
// With max_size=10, smart shortening kicks in
let shortened = PathShortenStrategy::Middle.shorten_path(path, 10);
assert!(shortened.len() <= 10);
}
#[test]
fn test_prioritizes_last_component() {
// When space allows, last component should be shown in full
let path = Path::new("first/medium/last_component");
// With enough space, last component should be intact
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 25);
assert!(
shortened.ends_with("last_component"),
"Should preserve last component when space allows, got '{}'",
shortened
);
assert!(shortened.len() <= 25);
// When space is too tight, last component may be truncated to fit
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 10);
assert!(
shortened.len() <= 10,
"Must fit within max_size, got '{}' ({} chars)",
shortened,
shortened.len()
);
}
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "fff-query-parser"
version = "0.2.4"
edition = "2024"
description = "Query parser for fff file finder - includes specific syntax for various constraints like globs, extensions, regex etc"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
[lib]
path = "src/lib.rs"
[features]
default = []
zlob = ["dep:zlob"]
[dependencies]
smallvec = { workspace = true }
zlob = { workspace = true, optional = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "parse_bench"
harness = false
@@ -0,0 +1,180 @@
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
use fff_query_parser::*;
fn bench_parse_simple(c: &mut Criterion) {
let parser = QueryParser::default();
c.bench_function("parse_simple_text", |b| {
b.iter(|| parser.parse(black_box("hello world")));
});
c.bench_function("parse_extension", |b| {
b.iter(|| parser.parse(black_box("*.rs")));
});
c.bench_function("parse_text_with_extension", |b| {
b.iter(|| parser.parse(black_box("name *.rs")));
});
}
fn bench_parse_complex(c: &mut Criterion) {
let parser = QueryParser::default();
c.bench_function("parse_complex_mixed", |b| {
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
});
c.bench_function("parse_glob", |b| {
b.iter(|| parser.parse(black_box("**/*.rs")));
});
c.bench_function("parse_multiple_constraints", |b| {
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
});
}
fn bench_parse_realistic_queries(c: &mut Criterion) {
let parser = QueryParser::default();
let queries = vec![
"file",
"test",
"mod.rs",
"src/*.rs",
"lib test",
"*.rs !test",
"src/lib/*.rs",
"/src/ name",
"status:modified *.rs",
"type:rust test !node_modules",
];
let mut group = c.benchmark_group("realistic_queries");
for query in queries.iter() {
group.throughput(Throughput::Bytes(query.len() as u64));
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
}
group.finish();
}
fn bench_parse_various_lengths(c: &mut Criterion) {
let parser = QueryParser::default();
let short = "*.rs";
let medium = "src name *.rs !test";
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
let very_long =
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
let mut group = c.benchmark_group("query_lengths");
group.throughput(Throughput::Bytes(short.len() as u64));
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(medium.len() as u64));
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(long.len() as u64));
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
b.iter(|| parser.parse(black_box(q)));
});
group.throughput(Throughput::Bytes(very_long.len() as u64));
group.bench_with_input(
BenchmarkId::new("very_long", very_long.len()),
&very_long,
|b, q| {
b.iter(|| parser.parse(black_box(q)));
},
);
group.finish();
}
fn bench_config_comparison(c: &mut Criterion) {
let file_picker = QueryParser::new(FileSearchConfig);
let grep = QueryParser::new(GrepConfig);
let query = "src name *.rs !test";
let mut group = c.benchmark_group("config_comparison");
group.bench_function("file_picker_config", |b| {
b.iter(|| file_picker.parse(black_box(query)));
});
group.bench_function("grep_config", |b| {
b.iter(|| grep.parse(black_box(query)));
});
group.finish();
}
fn bench_constraint_types(c: &mut Criterion) {
let parser = QueryParser::default();
let mut group = c.benchmark_group("constraint_types");
group.bench_function("extension", |b| {
b.iter(|| parser.parse(black_box("*.rs")));
});
group.bench_function("glob", |b| {
b.iter(|| parser.parse(black_box("**/*.rs")));
});
group.bench_function("exclude", |b| {
b.iter(|| parser.parse(black_box("!test")));
});
group.bench_function("path_segment", |b| {
b.iter(|| parser.parse(black_box("/src/")));
});
group.bench_function("git_status", |b| {
b.iter(|| parser.parse(black_box("status:modified")));
});
group.bench_function("file_type", |b| {
b.iter(|| parser.parse(black_box("type:rust")));
});
group.finish();
}
fn bench_worst_case(c: &mut Criterion) {
let parser = QueryParser::default();
// Worst case: many constraints that all need to be checked
let worst_case = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
c.bench_function("worst_case_many_text_tokens", |b| {
b.iter(|| parser.parse(black_box(worst_case)));
});
// Many constraints
let many_constraints = "*.rs *.toml *.md *.txt *.js *.ts *.jsx *.tsx *.vue *.svelte";
c.bench_function("worst_case_many_constraints", |b| {
b.iter(|| parser.parse(black_box(many_constraints)));
});
}
criterion_group!(
benches,
bench_parse_simple,
bench_parse_complex,
bench_parse_realistic_queries,
bench_parse_various_lengths,
bench_config_comparison,
bench_constraint_types,
bench_worst_case,
);
criterion_main!(benches);
+234
View File
@@ -0,0 +1,234 @@
use crate::constraints::Constraint;
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 {
fn enable_glob(&self) -> bool {
true
}
/// Should parse extension shortcuts (e.g., *.rs)
fn enable_extension(&self) -> bool {
true
}
/// Should parse exclusion patterns (e.g., !test)
fn enable_exclude(&self) -> bool {
true
}
/// Should parse path segments (e.g., /src/)
fn enable_path_segments(&self) -> bool {
true
}
/// Should parse type constraints (e.g., type:rust)
fn enable_type_filter(&self) -> bool {
true
}
/// Should parse git status (e.g., status:modified)
fn enable_git_status(&self) -> bool {
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
/// `RECOMMENDED` flags, which recognises `*`, `?`, `[`, `{…}` etc.
///
/// 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)
}
/// Custom constraint parsers for picker-specific needs
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
None
}
}
/// Default configuration for file picker - all features enabled
#[derive(Debug, Clone, Copy, Default)]
pub struct FileSearchConfig;
impl ParserConfig for FileSearchConfig {
/// 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
/// filtering which files to search, git status disabled since it's not useful
/// when searching file contents.
///
/// Glob detection is narrowed: only patterns containing a path separator (`/`)
/// or brace expansion (`{…}`) are treated as globs. Characters like `?` and
/// `[` are extremely common in source code and must remain literal search text.
#[derive(Debug, Clone, Copy, Default)]
pub struct GrepConfig;
impl ParserConfig for GrepConfig {
fn enable_path_segments(&self) -> bool {
true
}
fn enable_git_status(&self) -> bool {
false
}
fn enable_location(&self) -> bool {
false
}
/// Only recognise globs that are clearly directory/path oriented.
///
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
/// common in source code (`foo?`, `arr[0]`, `*ptr`) and must NOT be
/// consumed as glob constraints. We only treat a token as a glob when
/// it contains path-oriented patterns:
///
/// - Contains `/` → path glob (e.g. `src/**/*.rs`, `*/tests/*`)
/// - 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) {
return false;
}
let bytes = token.as_bytes();
// Contains path separator → clearly a path glob
if 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
}
}
}
@@ -0,0 +1,49 @@
use smallvec::SmallVec;
/// Constraint types that can be extracted from a query
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Constraint<'a> {
/// Match file extension: *.rs -> Extension("rs")
Extension(&'a str),
/// Glob pattern: **/*.rs -> Glob("**/*.rs")
Glob(&'a str),
/// Multiple text search parts: ["src", "name"]
/// Uses slice to avoid allocation
Parts(&'a [&'a str]),
/// Single text token (optimized case)
Text(&'a str),
/// Exclude pattern: !test -> Exclude(&["test"])
Exclude(&'a [&'a str]),
/// 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),
/// Git status constraint: status:modified -> GitStatus(Modified)
GitStatus(GitStatusFilter),
/// Negation constraint: !extension:rs -> Not(Extension("rs"))
/// Negates the inner constraint
Not(Box<Constraint<'a>>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitStatusFilter {
Modified,
Untracked,
Staged,
Unmodified,
}
/// Stack-allocated buffer for text parts (up to 16 parts without heap allocation)
pub(crate) type TextPartsBuffer<'a> = SmallVec<[&'a str; 16]>;
@@ -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'{'))
}
+236
View File
@@ -0,0 +1,236 @@
//! Fast, zero-allocation query parser for file search
//!
//! This parser takes a search query and extracts structured constraints
//! while preserving text for fuzzy matching. Designed for maximum performance:
//! - Zero allocations for queries with ≤8 constraints (SmallVec)
//! - Single-pass parsing with minimal branching
//! - Stack-allocated string buffers
//!
//! # Examples
//!
//! ```
//! use fff_query_parser::{QueryParser, Constraint, FuzzyQuery};
//!
//! let parser = QueryParser::default();
//!
//! // Single-token queries return FFFQuery with Text fuzzy query and no constraints
//! let result = parser.parse("hello");
//! assert!(result.constraints.is_empty());
//! assert_eq!(result.fuzzy_query, FuzzyQuery::Text("hello"));
//!
//! // Multi-token queries are parsed
//! let result = parser.parse("name *.rs");
//! match &result.fuzzy_query {
//! FuzzyQuery::Text(text) => assert_eq!(*text, "name"),
//! _ => panic!("Expected text"),
//! }
//! assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
//!
//! // Parse glob pattern with text
//! let result = parser.parse("**/*.rs foo");
//! assert!(matches!(result.constraints[0], Constraint::Glob("**/*.rs")));
//!
//! // Parse negation
//! let result = parser.parse("!*.rs foo");
//! match &result.constraints[0] {
//! Constraint::Not(inner) => {
//! assert!(matches!(inner.as_ref(), Constraint::Extension("rs")));
//! }
//! _ => panic!("Expected Not constraint"),
//! }
//! ```
mod config;
mod constraints;
pub mod glob_detect;
pub mod location;
mod parser;
pub use config::{AiGrepConfig, FileSearchConfig, GrepConfig, ParserConfig};
pub use constraints::{Constraint, GitStatusFilter};
pub use location::Location;
pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
// Re-export SmallVec for convenience
pub use smallvec::SmallVec;
pub type ConstraintVec<'a> = SmallVec<[Constraint<'a>; 8]>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_query() {
let parser = QueryParser::default();
let result = parser.parse("");
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(" ");
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");
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");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], "hello");
assert_eq!(parts[1], "world");
}
_ => panic!("Expected Parts fuzzy query"),
}
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_extension_only() {
let parser = QueryParser::default();
// Single constraint token - returns Some so constraint can be applied
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")));
}
#[test]
fn test_glob_pattern() {
let parser = QueryParser::default();
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] {
Constraint::Glob(pattern) => assert_eq!(*pattern, "**/*.rs"),
other => panic!("Expected Glob constraint, got {:?}", other),
}
}
#[test]
fn test_negation_pattern() {
let parser = QueryParser::default();
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
assert!(matches!(**inner, Constraint::Text("test")));
}
_ => panic!("Expected Not constraint"),
}
}
#[test]
fn test_path_segment() {
let parser = QueryParser::default();
let result = parser.parse("/src/ foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::PathSegment("src")
));
}
#[test]
fn test_git_status() {
let parser = QueryParser::default();
let result = parser.parse("status:modified foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::GitStatus(GitStatusFilter::Modified)
));
}
#[test]
fn test_file_type() {
let parser = QueryParser::default();
let result = parser.parse("type:rust foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::FileType("rust")
));
}
#[test]
fn test_complex_query() {
let parser = QueryParser::default();
let result = parser.parse("src name *.rs !test /lib/ status:modified");
// Verify we have fuzzy text
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], "src");
assert_eq!(parts[1], "name");
}
_ => panic!("Expected Parts fuzzy query"),
}
// Should have multiple constraints
assert!(result.constraints.len() >= 4);
// Verify specific constraints exist
let has_extension = result
.constraints
.iter()
.any(|c| matches!(c, Constraint::Extension("rs")));
let has_not = result
.constraints
.iter()
.any(|c| matches!(c, Constraint::Not(_)));
let has_path = result
.constraints
.iter()
.any(|c| matches!(c, Constraint::PathSegment("lib")));
let has_git_status = result
.constraints
.iter()
.any(|c| matches!(c, Constraint::GitStatus(_)));
assert!(has_extension, "Should have Extension constraint");
assert!(has_not, "Should have Not constraint");
assert!(has_path, "Should have PathSegment constraint");
assert!(has_git_status, "Should have GitStatus constraint");
}
#[test]
fn test_no_heap_allocation_for_small_queries() {
let parser = QueryParser::default();
let result = parser.parse("*.rs *.toml !test");
// SmallVec should not have spilled to heap
assert!(!result.constraints.spilled());
}
#[test]
fn test_many_fuzzy_parts() {
let parser = QueryParser::default();
let result = parser.parse("one two three four five six");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
assert_eq!(parts.len(), 6);
assert_eq!(parts[0], "one");
assert_eq!(parts[5], "six");
}
_ => panic!("Expected Parts fuzzy query"),
}
}
}
@@ -1,3 +1,14 @@
//! Location parsing for file:line:col patterns
//!
//! Parses various location formats like:
//! - `file:12` - Line number
//! - `file:12:4` - Line and column
//! - `file:12-114` - Line range
//! - `file:12:4-20` - Column range on same line
//! - `file:12:4-14:20` - Position range
//! - `file(12)` - Visual Studio style line
//! - `file(12,4)` - Visual Studio style line and column
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum Location {
Line(i32),
@@ -143,6 +154,22 @@ fn parse_vstudio_location(query: &str) -> Option<(&str, Location)> {
None
}
/// Parse location from the end of a query string.
///
/// Returns the query without the location suffix, and the parsed location if found.
///
/// # Examples
/// ```
/// use fff_query_parser::location::{parse_location, Location};
///
/// let (query, loc) = parse_location("file:12");
/// assert_eq!(query, "file");
/// assert_eq!(loc, Some(Location::Line(12)));
///
/// let (query, loc) = parse_location("search term");
/// assert_eq!(query, "search term");
/// assert_eq!(loc, None);
/// ```
pub fn parse_location(query: &str) -> (&str, Option<Location>) {
// simply ignore the last semicolon even if there are no additional location info
let query = query.trim_end_matches([':', '-', '(']);
@@ -159,7 +186,7 @@ pub fn parse_location(query: &str) -> (&str, Option<Location>) {
#[cfg(test)]
mod tests {
pub use super::*;
use super::*;
#[test]
fn test_location_parsing() {
File diff suppressed because it is too large Load Diff
+339 -98
View File
@@ -1,53 +1,68 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 October 17
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 18
==============================================================================
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.
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
@@ -75,7 +90,26 @@ LAZY.NVIM
"ff", -- try it if you didn't it is a banger keybinding for a picker
function() require('fff').find_files() end,
desc = 'FFFind files',
}
},
{
"fg",
function() require('fff').live_grep() end,
desc = 'LiFFFe grep',
},
{
"fz",
function() require('fff').live_grep({
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',
},
}
}
<
@@ -86,10 +120,10 @@ VIM.PACK
>lua
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
nvim.create_autocmd('PackChanged', {
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
require('fff.download').download_or_build_binary()
if event.data.updated then
require('fff.download').download_or_build_binary()
end
end,
})
@@ -128,11 +162,20 @@ all available options:
layout = {
height = 0.8,
width = 0.8,
row = nil, -- ratio (0.0 = top edge, 1.0 = bottom edge) nil is centered
col = nil, -- ratio (0.0 = left edge, 1.0 = right edge) nil is centered
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)
-- 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,
@@ -141,8 +184,8 @@ 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,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
@@ -155,37 +198,106 @@ all available options:
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',
},
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',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
git_deleted = 'FFFGitDeleted',
git_renamed = 'FFFGitRenamed',
git_untracked = 'FFFGitUntracked',
git_ignored = 'FFFGitIgnored',
-- Git sign/border highlights
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
-- Git sign selected highlights
git_sign_staged_selected = 'FFFGitSignStagedSelected',
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
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
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
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,
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 = 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
},
})
<
@@ -195,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
@@ -217,26 +330,6 @@ FFF.nvim provides several commands for interacting with the file picker:
- `:FFFOpenLog` - Open the FFF log file in a new tab
MULTIPLE KEY BINDINGS
You can assign multiple key combinations to the same action:
>lua
keymaps = {
move_up = { '<Up>', '<C-p>', '<C-k>' }, -- Three ways to move up
close = { '<Esc>', '<C-c>' }, -- Two ways to close
select = '<CR>', -- Single binding still works
}
<
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:
@@ -246,6 +339,184 @@ Toggle scoring information display:
- Enable by default with `debug.show_scores = true`
MULTI-SELECT AND QUICKFIX INTEGRATION
Select multiple files and send them to Neovims quickfix list (keymaps are
configurable):
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
- `<C-q>` - Send selected files to quickfix list and close picker
LIVE GREP SEARCH MODES
Live grep supports three search modes, cycled with `<S-Tab>`:
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
The current mode is shown on the right side of the input field (e.g., `plain`,
`regex`, `fuzzy`) with color-coded highlighting.
You can customize which modes are available and their cycling order globally in
your configuration, or per-call when invoking `live_grep()`.
**Global configuration:**
>lua
require('fff').setup({
grep = {
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
}
})
<
**Per-call configuration:**
>lua
-- Only fuzzy and plain modes for this specific grep
require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' },
}
})
-- Single mode (hides mode indicator completely)
require('fff').live_grep({
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:
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
- **Grep search with no matches** → shows suggested **file name matches** for the same query
Suggestions are clearly labeled with a "No results found. Suggested …" banner
(highlighted with `hl.suggestion_header`). You can navigate and select
suggestion items just like normal results — selecting a grep suggestion will
open the file at the matching line.
GIT STATUS HIGHLIGHTING
FFF integrates with git to show file status through sign column indicators
(enabled by default) and optional filename text coloring.
**Sign Column Indicators** (enabled by default) - Border characters shown in
the sign column:
>lua
hl = {
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
}
<
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
To enable git status text coloring, set `git.status_text_color = true`:
>lua
require('fff').setup({
git = {
status_text_color = true, -- Enable git status colors on filename text
},
hl = {
git_staged = 'FFFGitStaged', -- Files staged for commit
git_modified = 'FFFGitModified', -- Modified unstaged files
git_deleted = 'FFFGitDeleted', -- Deleted files
git_renamed = 'FFFGitRenamed', -- Renamed files
git_untracked = 'FFFGitUntracked', -- New untracked files
git_ignored = 'FFFGitIgnored', -- Git-ignored files
}
})
<
The plugin provides sensible default highlight groups that link to common git
highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these
with your own custom highlight groups to match your colorscheme.
**Example - Custom Bright Colors for Text:**
>lua
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
require('fff').setup({
git = {
status_text_color = true,
},
hl = {
git_modified = 'CustomGitModified',
git_untracked = 'CustomGitUntracked',
}
})
<
FILE FILTERING
FFF.nvim respects `.gitignore` patterns automatically. To filter files from the
picker without modifying `.gitignore`, create a `.ignore` file in your project
root:
>gitignore
# Exclude all markdown files
*.md
# Exclude specific subdirectory
docs/archive/**/*.md
<
Run `:FFFScan` to force a rescan if needed.
TROUBLESHOOTING ~
@@ -263,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>
+7 -1
View File
@@ -17,7 +17,13 @@ vim.opt.rtp:prepend(lazypath)
require('lazy').setup({
{
dir = '/Users/neogoose/dev/fff.nvim',
dir = '~/dev/fff.nvim',
'https://github.com/dmtrKovalenko/fff.nvim',
build = function()
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
require('fff.download').download_or_build_binary()
end,
dependencies = {
'nvim-tree/nvim-web-devicons', -- Optional: for file icons
-- {
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1765739568,
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
"lastModified": 1773189535,
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
"owner": "ipetkov",
"repo": "crane",
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1765644376,
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
"lastModified": 1773597492,
"narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
"rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1765680428,
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
"lastModified": 1773716879,
"narHash": "sha256-vXCTasEzzTTd0ZGEuyle20H2hjRom66JeNr7i2ktHD0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
"rev": "1a9ddeb45c5751b800331363703641b84d1f41f0",
"type": "github"
},
"original": {
+7 -2
View File
@@ -35,24 +35,29 @@
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;
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl ];
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl pkgs.zig pkgs.llvmPackages.libclang.lib ];
buildInputs = with pkgs; [
# Add additional build inputs here
openssl
];
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
};
my-crate = craneLib.buildPackage (
commonArgs
// {
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
doCheck = false;
}
);
# Copies the dynamic library into the target/release folder
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
+62 -26
View File
@@ -10,6 +10,8 @@ local overlay_state = {
last_row = nil,
last_col = nil,
last_border_hl = nil,
-- Track if combo was rendered in last call
was_rendered = false,
}
local LEFT_OVERLAY_CONTENT = '├────'
@@ -39,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
@@ -55,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)
@@ -91,6 +93,7 @@ local function position_overlay_window(state_key, buf, width, row, col)
row = row,
col = col,
style = 'minimal',
border = 'none',
focusable = false,
zindex = 250,
}
@@ -98,14 +101,21 @@ 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)
local list_config = vim.api.nvim_win_get_config(list_win)
-- combo_header_line is a 1-based buffer line index (includes any padding offset)
-- list_config.row is where the window starts (0-based, at the top border)
-- Content starts at list_config.row + 1 (after the top border)
-- Buffer line 1 -> screen row (list_config.row + 1)
-- Buffer line N -> screen row (list_config.row + N)
-- Since combo_header_line is 1-based, the formula naturally works out
local combo_header_row = list_config.row + combo_header_line
-- Skip update if position and highlight haven't changed
@@ -121,20 +131,16 @@ local function update_overlays(list_win, combo_header_line, border_hl)
return
end
-- Cache current values
overlay_state.last_row = combo_header_row
overlay_state.last_col = list_config.col
overlay_state.last_border_hl = border_hl
-- Update both overlays in a batch to minimize API calls
local left_buf = get_or_create_overlay_buf('left_buf')
local right_buf = get_or_create_overlay_buf('right_buf')
-- Update content for both buffers
update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl)
update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl)
-- Position both windows
position_overlay_window('left_win', left_buf, LEFT_OVERLAY_WIDTH, combo_header_row, list_config.col)
position_overlay_window(
'right_win',
@@ -156,10 +162,10 @@ local function clear_overlays_internal()
overlay_state.right_win = nil
end
-- Clear cache
overlay_state.last_row = nil
overlay_state.last_col = nil
overlay_state.last_border_hl = nil
-- Note: we intentionally don't clear was_rendered here to track the transition
end
function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display)
@@ -171,6 +177,8 @@ function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_m
return true, header_line, text_len, combo_item_index
end
--- Render combo highlights and overlays
--- @return boolean was_hidden True if combo was just hidden (was rendered before, not now)
function M.render_highlights_and_overlays(
combo_item_index,
text_len,
@@ -178,26 +186,54 @@ function M.render_highlights_and_overlays(
list_win,
ns_id,
border_hl,
item_to_lines
item_to_lines,
prompt_position,
total_items
)
local was_rendered_before = overlay_state.was_rendered
local is_rendering_now = false
if not combo_item_index then
clear_overlays_internal()
return
else
local combo_item_lines = item_to_lines[combo_item_index]
if not combo_item_lines then
clear_overlays_internal()
else
local combo_header_line_idx = combo_item_lines.first
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
if prompt_position == 'bottom' and total_items and total_items > 1 then
combo_header_line_idx = combo_header_line_idx - 1
end
-- when rendering items in the reverse order for some reason this makes the
-- indexing shifted by one in the internal list config, so just adjust for that
update_overlays(list_win, combo_header_line_idx, border_hl)
is_rendering_now = true
end
end
local combo_item_lines = item_to_lines[combo_item_index]
if not combo_item_lines then
clear_overlays_internal()
return
end
overlay_state.was_rendered = is_rendering_now
local combo_header_line_idx = combo_item_lines.first
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
update_overlays(list_win, combo_header_line_idx, border_hl)
-- Return true if combo was just hidden (transition from visible to hidden)
return was_rendered_before and not is_rendering_now
end
--- Get the combo header text for a given item
--- @param combo_count number The combo multiplier count
--- @param win_width number Window width for formatting
--- @param disable_combo_display boolean Whether to show combo count
--- @return string header_text The formatted header line
--- @return number text_len Length of the header text (without padding)
function M.get_combo_header_text(combo_count, win_width, disable_combo_display)
return create_header_text(combo_count, win_width, disable_combo_display)
end
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
function M.cleanup() clear_overlays_internal() end
function M.cleanup()
clear_overlays_internal()
overlay_state.was_rendered = false
end
return M
+149 -9
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,12 +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
-- 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,
@@ -121,8 +220,8 @@ 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,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
@@ -135,24 +234,38 @@ 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',
},
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',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
@@ -174,26 +287,52 @@ local function init()
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
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
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
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,
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 = 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
},
}
local migrated_user_config = handle_deprecated_config(config)
@@ -203,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
@@ -216,6 +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.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
+11 -9
View File
@@ -31,7 +31,9 @@ local function setup_global_autocmds(config)
local ok, track_err = pcall(fuzzy.track_access, real_path)
if not ok then
vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR)
vim.schedule(
function() vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR) end
)
end
end)
end)
@@ -44,12 +46,18 @@ 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()
local picker = require('fff.main')
local ok, err = pcall(picker.change_indexing_directory, new_cwd)
-- Delay require to avoid circular dependency: core -> main -> picker_ui -> file_picker -> core
local ok, picker = pcall(require, 'fff.main')
if not ok then
vim.notify('FFF: Failed to load main module: ' .. tostring(picker), vim.log.levels.ERROR)
return
end
local change_ok, err = pcall(picker.change_indexing_directory, new_cwd)
if not change_ok then
vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR)
end
end)
@@ -57,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
+103 -20
View File
@@ -1,6 +1,6 @@
local M = {}
local system = require('fff.utils.system')
local uv = vim and vim.uv or require('luv')
local fs_utils = require('fff.utils.fs')
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
@@ -14,7 +14,7 @@ local function get_current_version(plugin_dir, callback)
end)
end
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target' end
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
local function get_binary_path(plugin_dir)
local binary_dir = get_binary_dir(plugin_dir)
@@ -24,17 +24,36 @@ end
local function binary_exists(plugin_dir)
local binary_path = get_binary_path(plugin_dir)
local stat = uv.fs_stat(binary_path)
return stat and stat.type == 'file'
local stat = vim.uv.fs_stat(binary_path)
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)
opts = opts or {}
local dir = vim.fn.fnamemodify(output_path, ':h')
uv.fs_mkdir(dir, 493, function(err) -- 493 = 0755 octal
if err and not err:match('EEXIST') then
callback(false, 'Failed to create directory: ' .. err)
fs_utils.mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
if not mkdir_ok then
callback(false, mkdir_err)
return
end
@@ -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
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 -3
View File
@@ -21,13 +21,11 @@ function M.setup()
if ok then
M.provider = provider
M.provider_name = provider_name
vim.notify('FFF Icons: Loaded provider ' .. provider_name, vim.log.levels.INFO)
return true
end
end
M.setup_failed = true
vim.notify('FFF Icons: No icon provider found', vim.log.levels.WARN)
return false
end
@@ -46,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
+53 -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
@@ -225,4 +224,41 @@ function M.display_image(file_path, bufnr, max_width, max_height)
return false
end
--- Check image preview availability status
--- @return table status { available: boolean, snacks_available: boolean, snacks_image_available: boolean, terminal_supported: boolean, error: string|nil }
function M.get_preview_status()
local status = {
available = false,
snacks_available = false,
snacks_image_available = false,
terminal_supported = false,
error = nil,
}
local ok, snacks = pcall(require, 'snacks')
if not ok then
status.error = 'snacks.nvim not installed'
return status
end
status.snacks_available = true
if not snacks.image then
status.error = 'snacks.image module not available'
return status
end
status.snacks_image_available = true
if not snacks.image.supports_terminal or not snacks.image.supports_terminal() then
status.error = 'terminal does not support image display'
return status
end
status.terminal_supported = true
status.available = true
return status
end
return M
+27 -10
View File
@@ -32,43 +32,60 @@ function M.scan_files()
end
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param max_results number|nil Maximum number of results (optional)
--- @param max_threads number|nil Maximum number of threads (optional)
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @param reverse_order boolean Reverse order of results
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @return table List of matching files
function M.search_files(query, current_file, max_results, max_threads, reverse_order, min_combo_count_override)
function M.search_files(query, current_file, max_results, max_threads, min_combo_count_override)
-- Delegate to paginated version with offset=0 and limit=max_results
return M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, 0, max_results)
end
--- Search files with pagination support
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @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|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()
if not M.state.initialized then return {} end
max_results = max_results or config.max_results or 40
max_threads = max_threads or config.max_threads or 4
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
page_index = page_index or 0
page_size = page_size or 0
-- Use override if provided, otherwise use config value
local min_combo_count = min_combo_count_override
if min_combo_count == nil then min_combo_count = config.history and config.history.min_combo_count or 3 end
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
-- Convert page_index to offset (Rust expects offset in items, not page number)
local offset = page_index * page_size
local ok, search_result = pcall(
fuzzy.fuzzy_search_files,
query,
max_results,
max_threads,
current_file,
reverse_order,
combo_boost_score_multiplier,
min_combo_count
min_combo_count,
offset,
page_size
)
if not ok then
vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR)
return {}
end
-- Store search metadata for UI display
M.state.last_search_result = search_result
return search_result.items
end
+338 -324
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)
@@ -71,6 +46,32 @@ local function cleanup_file_operation()
end
end
--- Process raw chunk data into complete lines, joining any leftover bytes
--- from the previous chunk and storing any trailing partial line for the next.
--- @param data string Raw chunk data
--- @return string[] Complete lines (may be empty if the entire chunk is a partial line)
local function split_chunk_with_remainder(data)
if not data or data == '' then return {} end
local fo = M.state.file_operation
local prefix = fo and fo.remainder or ''
local combined = prefix .. data
local lines = vim.split(combined, '\n', { plain = true })
if combined:sub(-1) ~= '\n' then
-- Data doesn't end on a line boundary: last element is a partial line
local partial = table.remove(lines) or ''
if fo then fo.remainder = partial end
else
-- Data ends on a line boundary: remove the trailing empty element
if #lines > 0 and lines[#lines] == '' then table.remove(lines) end
if fo then fo.remainder = '' end
end
return lines
end
local function init_dynamic_loading_async(file_path, callback)
cleanup_file_operation()
@@ -79,7 +80,15 @@ local function init_dynamic_loading_async(file_path, callback)
M.state.has_more_content = true
M.state.is_loading = false
local generation = M.state.preview_generation
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
-- Stale callback: preview moved on to a different file
if M.state.preview_generation ~= generation then
if fd then pcall(vim.uv.fs_close, fd) end
return
end
if err or not fd then
callback(false, 'Failed to open file: ' .. (err or 'unknown error'))
return
@@ -89,6 +98,7 @@ local function init_dynamic_loading_async(file_path, callback)
fd = fd,
file_path = file_path,
position = 0,
remainder = '',
}
callback(true)
@@ -103,9 +113,13 @@ local function load_forward_chunk_async(target_size, callback)
M.state.is_loading = true
local chunk_size = target_size or (M.config.chunk_size or 16384)
local generation = M.state.preview_generation
vim.uv.fs_read(M.state.file_operation.fd, chunk_size, M.state.file_operation.position, function(err, data)
vim.schedule(function()
-- Stale callback: a newer preview has started, discard this result
if M.state.preview_generation ~= generation then return end
M.state.is_loading = false
if err then
@@ -115,8 +129,14 @@ local function load_forward_chunk_async(target_size, callback)
if not data or #data == 0 then
M.state.has_more_content = false
-- Flush any remaining partial line as the final piece of data
local final_remainder = M.state.file_operation and M.state.file_operation.remainder or ''
cleanup_file_operation()
callback('', nil)
if final_remainder ~= '' then
callback(final_remainder .. '\n', nil)
else
callback('', nil)
end
return
end
@@ -135,8 +155,15 @@ local function load_next_chunk_async(chunk_size, callback)
load_forward_chunk_async(chunk_size, callback)
end
local function read_file_streaming_async(file_path, bufnr, callback)
-- 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, callback)
local generation = M.state.preview_generation
init_dynamic_loading_async(file_path, function(success, error_msg)
if M.state.preview_generation ~= generation then return end
if not success then
callback(nil, error_msg)
return
@@ -159,22 +186,26 @@ local function read_file_streaming_async(file_path, bufnr, callback)
end
load_next_chunk_async(initial_chunk_size, function(data, err)
if M.state.preview_generation ~= generation then return end
if data and data ~= '' then
-- there seems to be no other way to append the buffer other than the lines :(
local lines = vim.split(data, '\n', { plain = true })
local lines = split_chunk_with_remainder(data)
M.state.loaded_lines = #lines
M.state.content_height = #lines
-- If we have a location and didn't load enough lines, try to load more
local loading_more = false
if M.state.location then
local target_line = location_utils.get_target_line(M.state.location)
if target_line and #lines < target_line and M.state.has_more_content then
-- Schedule additional loading after the initial callback
vim.schedule(function() ensure_content_loaded_async(target_line) end)
loading_more = true
vim.schedule(function()
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
end)
end
end
callback(lines, err)
callback(lines, err, loading_more)
else
callback(nil, err)
end
@@ -182,55 +213,58 @@ local function read_file_streaming_async(file_path, bufnr, callback)
end)
end
local function ensure_content_loaded_async(target_line)
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
if current_buffer_lines >= buffer_needed then return end
if current_buffer_lines < buffer_needed then
local loading_line = string.format('Loading more content... (%d lines loaded)', M.state.loaded_lines)
append_buffer_lines(M.state.bufnr, { '', loading_line })
end
local generation = M.state.preview_generation
load_next_chunk_async(M.config.chunk_size, function(data, err)
if err then
vim.notify('Error loading file content: ' .. err, vim.log.levels.ERROR)
-- Remove loading message on error
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
if total_lines >= 2 then
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
set_buffer_lines(M.state.bufnr, existing_lines)
end
return
end
-- Use a larger chunk to reach the target faster instead of many small 8KB reads
local lines_needed = buffer_needed - current_buffer_lines
local estimated_bytes = math.max(M.config.chunk_size, lines_needed * 120)
load_next_chunk_async(estimated_bytes, function(data, err)
-- Stale callback: preview moved on to a different file
if M.state.preview_generation ~= generation then return end
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if err then return end
if data and data ~= '' then
local chunk_lines = vim.split(data, '\n', { plain = true })
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
if total_lines >= 2 then
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
local new_content = vim.list_extend(existing_lines, chunk_lines)
set_buffer_lines(M.state.bufnr, new_content)
else
append_buffer_lines(M.state.bufnr, chunk_lines)
end
local chunk_lines = split_chunk_with_remainder(data)
if #chunk_lines > 0 then append_buffer_lines(M.state.bufnr, chunk_lines) end
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
M.state.loaded_lines = M.state.content_height
else
-- No more data available - remove the loading message
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
if total_lines >= 2 then
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
set_buffer_lines(M.state.bufnr, existing_lines)
M.state.content_height = #existing_lines
M.state.loaded_lines = M.state.content_height
-- If we still haven't loaded enough, schedule another chunk
if M.state.loaded_lines < buffer_needed and M.state.has_more_content then
vim.schedule(function()
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
end)
else
-- Enough content loaded — re-apply location highlighting so the
-- preview scrolls to the correct line now that it exists in the buffer
M.apply_location_highlighting(M.state.bufnr)
end
else
-- 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)
end
@@ -239,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
@@ -263,10 +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
@@ -281,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
@@ -291,166 +327,6 @@ function M.is_big_file(file_path, bufnr)
return false
end
--- Check if file is binary (async version)
--- @param file_path string Path to the file
--- @param callback function Callback with (is_binary: boolean)
function M.is_binary_file_async(file_path, callback)
local ext = vim.fn.fnamemodify(file_path, ':e')
local binary_extensions = {
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'tiff',
'tif',
'webp',
'ico',
'pdf',
'ps',
'eps',
'heic',
'avif',
-- Archives
'zip',
'rar',
'7z',
'tar',
'gz',
'bz2',
'xz',
-- Executables
'exe',
'dll',
'so',
'dylib',
'bin',
-- Audio/Video
'mp3',
'mp4',
'avi',
'mkv',
'wav',
'flac',
'ogg',
-- Other binary formats
'db',
'sqlite',
'dat',
'bin',
'iso',
}
for _, binary_ext in ipairs(binary_extensions) do
if ext == binary_ext then
callback(true)
return
end
end
if M.config.binary_file_threshold <= 0 then
callback(false)
return
end
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
if err or not fd then
callback(false)
return
end
vim.uv.fs_read(fd, M.config.binary_file_threshold, 0, function(read_err, chunk)
vim.uv.fs_close(fd)
vim.schedule(function()
if read_err or not chunk then
callback(false)
return
end
if chunk:find('\0') then
callback(true)
return
end
local printable_count = 0
local total_count = #chunk
for i = 1, total_count do
local byte = chunk:byte(i)
-- Printable ASCII range + common control chars (tab, newline, carriage return)
if (byte >= 32 and byte <= 126) or byte == 9 or byte == 10 or byte == 13 then
printable_count = printable_count + 1
end
end
local printable_ratio = printable_count / total_count
callback(printable_ratio < 0.8) -- More aggressive: If less than 80% printable, consider binary
end)
end)
end)
end
--- Check if file is binary (sync version kept for compatibility)
--- @param file_path string Path to the file
--- @return boolean True if file appears to be binary
function M.is_binary_file(file_path)
local ext = vim.fn.fnamemodify(file_path, ':e')
local binary_extensions = {
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'tiff',
'tif',
'webp',
'ico',
'pdf',
'ps',
'eps',
'heic',
'avif',
-- Archives
'zip',
'rar',
'7z',
'tar',
'gz',
'bz2',
'xz',
-- Executables
'exe',
'dll',
'so',
'dylib',
'bin',
-- Audio/Video
'mp3',
'mp4',
'avi',
'mkv',
'wav',
'flac',
'ogg',
'aac',
-- Other binary formats
'db',
'sqlite',
'dat',
'bin',
'iso',
}
for _, binary_ext in ipairs(binary_extensions) do
if ext == binary_ext then return true end
end
-- For sync version, just return false for unknown extensions to avoid blocking
-- The main preview logic will handle this with async detection
return false
end
--- Get file information
--- @param file_path string Path to the file
--- @return table | nil File information
@@ -468,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)
@@ -479,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 = {}
@@ -536,13 +412,55 @@ function M.create_file_info_content(file, info, file_index)
return lines
end
--- Create file info content for grep mode items.
--- Shows grep-specific metadata: match location, frecency, file info.
---@param item table Grep match item with file + match metadata
---@param info table File system information from get_file_info
---@return table Lines for the file info content
function M.create_grep_file_info_content(item, info)
local lines = {}
-- Match location info
local match_count = item.match_ranges and #item.match_ranges or 0
table.insert(
lines,
string.format('Match: line %d, col %d │ Ranges: %d', item.line_number or 0, (item.col or 0) + 1, match_count)
)
table.insert(
lines,
string.format('Byte Offset: %-12d │ Size: %s', item.byte_offset or 0, info.size_formatted or 'N/A')
)
table.insert(lines, string.format('Type: %-8s │ Git: %s', info.filetype or 'text', item.git_status or 'clean'))
-- Fuzzy match score (only available in fuzzy grep mode)
if item.fuzzy_score then table.insert(lines, string.format('Fuzzy Score: %d', item.fuzzy_score)) end
-- Frecency info
local total = item.total_frecency_score or 0
local acc = item.access_frecency_score or 0
local mod = item.modification_frecency_score or 0
table.insert(lines, string.format('Frecency: total=%d, access=%d, modification=%d', total, acc, mod))
-- Ordering explanation
table.insert(lines, 'Order: files sorted by frecency desc, matches by line asc')
table.insert(lines, '')
-- Time information section
table.insert(lines, 'TIMINGS')
table.insert(lines, string.rep('', 50))
table.insert(lines, string.format('Modified: %s', info.modified_formatted or 'N/A'))
table.insert(lines, string.format('Last Access: %s', info.accessed_formatted or 'N/A'))
return lines
end
--- Preview a regular file
--- @param file_path string Path to the file
--- @param bufnr number Buffer number for preview
--- @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',
@@ -569,16 +487,20 @@ 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_buf_set_option(bufnr, 'number', M.config.line_numbers)
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
-- Apply location highlighting if available (delayed to ensure buffer is ready)
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
local gen = M.state.preview_generation
vim.schedule(function()
if M.state.preview_generation == gen then M.apply_location_highlighting(bufnr) end
end)
return true
end
@@ -586,10 +508,11 @@ function M.preview_file(file_path, bufnr)
M.state.current_file = file_path
M.state.bufnr = bufnr
local generation = M.state.preview_generation
read_file_streaming_async(file_path, bufnr, function(content, err)
if M.state.current_file ~= file_path then
-- User has moved to a different file, ignore this result
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()
return
end
@@ -602,29 +525,101 @@ function M.preview_file(file_path, bufnr)
end
if M.state.current_file == file_path then
-- Guard against buffer being destroyed while async read was in-flight
if not vim.api.nvim_buf_is_valid(bufnr) then return end
M.clear_preview_visual_state(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_buf_set_option(bufnr, 'number', M.config.line_numbers)
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
-- Apply location highlighting if available (delayed to ensure buffer is ready)
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
-- Apply location highlighting if available (delayed to ensure buffer is ready).
-- Skip when more content is being loaded asynchronously to reach the target line —
-- ensure_content_loaded_async will re-apply highlighting once the target is in the buffer.
if not loading_more then
vim.schedule(function()
if M.state.preview_generation == generation then M.apply_location_highlighting(bufnr) end
end)
end
end
end)
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
@@ -632,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
@@ -686,24 +672,23 @@ 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
--- @param file_path string Path to the file or directory
--- @param bufnr number Buffer number for preview
--- @param location table|nil Optional location data for highlighting
--- @param is_binary boolean|nil Whether the file is binary (from Rust indexer)
--- @return boolean if the preview was successful
function M.preview(file_path, bufnr, location)
if not file_path or file_path == '' then
-- Don't immediately clear - let the previous content stay visible
-- Only clear if we really need to show "No file selected"
-- M.clear_buffer(bufnr)
-- set_buffer_lines(bufnr, { 'No file selected' })
return false
end
function M.preview(file_path, bufnr, location, is_binary)
if not file_path or file_path == '' then return false end
-- Bump generation to invalidate any in-flight async callbacks from previous previews
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
@@ -712,6 +697,9 @@ function M.preview(file_path, bufnr, location)
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
@@ -722,11 +710,8 @@ function M.preview(file_path, bufnr, location)
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)
elseif M.is_binary_file(file_path) then
return image.display_image(file_path, bufnr)
elseif is_binary then
return M.preview_binary_file(file_path, bufnr)
else
return M.preview_file(file_path, bufnr)
@@ -746,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)
@@ -778,8 +767,9 @@ end
function M.set_preview_window(winid) M.state.winid = winid end
--- Update file info buffer
--- @param file table File information from search results
--- @param file table File information from search results (or grep match item)
--- @param bufnr number Buffer number for file info
--- @param file_index number|nil Index of the file in search results (for score lookup, file mode only)
--- @return boolean Success status
function M.update_file_info_buffer(file, bufnr, file_index)
if not file then
@@ -793,13 +783,24 @@ function M.update_file_info_buffer(file, bufnr, file_index)
return false
end
local file_info_lines = M.create_file_info_content(file, info, file_index)
-- Detect grep mode items by the presence of line_number (grep-specific field)
local file_info_lines
if file.line_number ~= nil then
file_info_lines = M.create_grep_file_info_content(file, info)
else
file_info_lines = M.create_file_info_content(file, info, 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
@@ -839,15 +840,18 @@ 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
function M.clear()
-- Bump generation to invalidate any in-flight async callbacks
M.state.preview_generation = M.state.preview_generation + 1
cleanup_file_operation()
M.state.loaded_lines = 0
@@ -861,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
@@ -878,12 +884,20 @@ 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
local target_line = location_utils.get_target_line(M.state.location)
if target_line then M.scroll_to_line(target_line) end
if target_line then
local buffer_lines = vim.api.nvim_buf_line_count(bufnr)
if target_line > buffer_lines and M.state.has_more_content then
-- Target line is beyond loaded content — load more first.
-- ensure_content_loaded_async will re-apply highlighting when done.
ensure_content_loaded_async(target_line)
return
end
M.scroll_to_line(target_line)
end
end
end
+241
View File
@@ -0,0 +1,241 @@
--- File Renderer
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
local M = {}
--- File Item structure from Rust
--- @class FileItem
--- @field path string Absolute file path
--- @field relative_path string Relative file path from base directory
--- @field name string File name
--- @field extension string File extension
--- @field size number File size in bytes
--- @field modified number Last modified timestamp
--- @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 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 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 = {}
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
local icon, _ = icons.get_icon(item.name, item.extension, false)
-- Build frecency indicator (debug mode only)
local frecency = ''
if ctx.debug_enabled then
local total = item.total_frecency_score or 0
local access = item.access_frecency_score or 0
local mod = item.modification_frecency_score or 0
if total > 0 then
local indicator = ''
if mod >= 6 then
indicator = '🔥'
elseif access >= 4 then
indicator = '⭐️'
elseif total >= 3 then
indicator = ''
elseif total >= 1 then
indicator = ''
end
frecency = string.format(' %s%d', indicator, total)
end
end
-- Format filename and path
-- Don't reserve space for frecency - path takes priority
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
local available_width = math.max(ctx.max_path_width - icon_width, 40)
local filename, dir_path = ctx.format_file_display(item, available_width)
-- Build line
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
or string.format('%s %s%s', filename, dir_path, frecency)
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
table.insert(lines, line .. string.rep(' ', padding))
return lines
end
--- Apply highlights to a rendered line
--- @param item FileItem File item from Rust
--- @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
--- @param line_idx number 1-based line index in buffer
--- @param line_content string The actual line content
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
local icons = require('fff.file_picker.icons')
local git_utils = require('fff.git_utils')
local file_picker = require('fff.file_picker')
local is_cursor = (ctx.cursor == item_idx)
local score = file_picker.get_file_score(item_idx)
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
-- Get icon and paths
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
local available_width = math.max(ctx.max_path_width - icon_width, 40)
local filename, dir_path = ctx.format_file_display(item, available_width)
-- 1. Cursor highlight
if is_cursor then
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.cursor,
hl_eol = true,
priority = 100,
})
end
-- 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_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)
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
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_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 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
-- 5. Directory path (dimmed)
if #filename > 0 and #dir_path > 0 then
local prefix_len = #filename + 1 -- filename bytes + space
if icon then
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
end
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
prefix_len,
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
)
end
-- 6. Current file
if is_current_file then
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 = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
virt_text_pos = 'right_align',
})
end
-- 7. Git sign
if item.git_status and git_utils.should_show_border(item.git_status) then
local border_char = git_utils.get_border_char(item.git_status)
local border_hl
if is_cursor then
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.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 })
border_hl = temp_hl_name
else
border_hl = git_utils.get_border_highlight_selected(item.git_status)
end
else
border_hl = ctx.config.hl.cursor
end
else
border_hl = git_utils.get_border_highlight(item.git_status)
end
if border_hl and border_hl ~= '' then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = border_char,
sign_hl_group = border_hl,
priority = 1000,
})
end
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.cursor,
priority = 1000,
})
end
-- 8. Selection
if ctx.selected_files and ctx.selected_files[item.path] then
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = '',
sign_hl_group = selection_hl,
priority = 1001,
})
end
-- 9. Query match
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_set_extmark(
buf,
ns_id,
line_idx - 1,
match_start - 1,
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
)
end
end
end
return M

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