Compare commits

...

33 Commits

Author SHA1 Message Date
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
Dmitriy Kovalenko 0312492570 chore: Add all highlights groups to config and improve icons highlight resolution (#190) 2025-12-14 17:15:30 -08:00
Dmitriy Kovalenko e3f788f87b feat: Resume last query + last same query match boost (#187) 2025-12-12 16:31:13 -08:00
Dmitriy Kovalenko 53acaf90ab fix: Make directory change file walking non-blocking (#189) 2025-12-10 16:42:53 -08:00
Dmitriy Kovalenko 2c55114048 fix: Outdated/wrong git status (#188)
Resolved internal error cause by our FS notifier backend which caused
outdated git status in the picker. This should eliminate the process
completely also improved the logging of errors and improved the
perfromance and logic around the handling background events
2025-12-08 18:51:41 -08:00
Dmitriy Kovalenko d88922e6c7 fix: Correctly count file access on reopen (#185) 2025-12-06 21:40:56 -08:00
Dmitriy Kovalenko 9edf195c8f feat: Significant performance improvements up to +190% (#182) 2025-12-05 23:44:14 -08:00
122 changed files with 21664 additions and 2139 deletions
+1 -7
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",
+87
View File
@@ -0,0 +1,87 @@
name: Lua E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13"
jobs:
lua-tests:
name: Lua E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
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@v4
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: true
cache-on-failure: true
cache-key: "v1-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}
- name: Build Rust binary (Windows)
if: matrix.target
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
- 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
- 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: |
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
+1 -1
View File
@@ -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 }}"
+346 -44
View File
@@ -2,11 +2,103 @@ name: Prebuild
on:
push:
branches: [main, feat/prebuild]
branches: [main, feat/binaries]
pull_request:
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
## 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@v4
with:
persist-credentials: false
- name: Install Rust
run: rustup target add ${{ matrix.target }}
- name: Install Zig
uses: mlugg/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-nvim
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
mv "${{ matrix.artifact_name }}" "${{ 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
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,87 +106,118 @@ 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-bun-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-bun-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-bun-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-bun-linux-arm64-musl
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-bun-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-bun-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-bun-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-bun-win32-arm64
lib_filename: fff_c.dll
ext: dll
steps:
- uses: actions/checkout@v4
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: mlugg/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 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
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
mv "${{ matrix.artifact_name }}" "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
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Upload artifacts
- name: Prepare npm package
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
uses: actions/upload-artifact@v4
with:
name: npm-${{ matrix.npm_package }}
path: packages/${{ matrix.npm_package }}/
release:
name: Release
needs: build
needs: [build-nvim, build-c]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -106,12 +229,49 @@ jobs:
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: 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
@@ -127,9 +287,151 @@ jobs:
name: "${{ steps.vars.outputs.tag }}"
tag_name: "${{ steps.vars.outputs.tag }}"
token: ${{ github.token }}
files: ./binaries/**/*
files: ./binaries/*
draft: false
prerelease: true
generate_release_notes: false
body: |
Nightly release from commit: ${{ github.sha }}
Nightly release from commit: ${{ 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
npm-publish:
name: Publish npm packages
needs: [build-c]
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/binaries'))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/binaries'))
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Determine version
id: version
run: |
# Read the base version from fff-core Cargo.toml (single source of truth)
base_version=$(grep '^version' crates/fff-core/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
short_sha=$(git rev-parse --short HEAD)
# Always publish as nightly prerelease: X.Y.Z-nightly.<short-sha>
echo "version=${base_version}-nightly.${short_sha}" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tag=nightly" >> $GITHUB_OUTPUT
else
echo "tag=dev" >> $GITHUB_OUTPUT
fi
- 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.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}..."
# Update version in package.json
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('${pkg_dir}package.json', 'utf8'));
pkg.version = '${VERSION}';
fs.writeFileSync('${pkg_dir}package.json', JSON.stringify(pkg, null, 2) + '\n');
"
cd "$pkg_dir"
npm publish --tag "$TAG" --access public || echo "Failed to publish ${pkg_name} (may already exist)"
cd -
fi
done
- name: Publish main package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
echo "Publishing @ff-labs/fff-bun@${VERSION} with tag ${TAG}..."
# Update version and optionalDependencies versions in the main package
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./packages/fff-bun/package.json', 'utf8'));
pkg.version = '${VERSION}';
if (pkg.optionalDependencies) {
for (const dep of Object.keys(pkg.optionalDependencies)) {
pkg.optionalDependencies[dep] = '${VERSION}';
}
}
fs.writeFileSync('./packages/fff-bun/package.json', JSON.stringify(pkg, null, 2) + '\n');
"
cd packages/fff-bun
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-bun (may already exist)"
comment-on-pr:
name: Comment on PR
needs: [build-nvim, build-c]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
steps:
- name: Get short SHA
id: vars
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- name: Find existing comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- fff-nvim-build-comment -->"
- name: Create or update PR comment
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
edit-mode: replace
body: |
<!-- fff-nvim-build-comment -->
## Build Artifacts for your PR
### Neovim Plugin
Test with lazy.nvim:
```lua
{
"dmtrKovalenko/fff.nvim",
tag = "${{ steps.vars.outputs.short_sha }}",
}
```
### Bun/TypeScript Package
The `fff` npm package will download binaries from this release automatically.
---
*Built from ${{ github.sha }}*
+22 -7
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,22 +18,26 @@ 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
# Zig is required to compile zlob
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
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 --verbose --workspace --exclude fff-nvim
fmt:
name: cargo fmt
@@ -50,10 +57,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Zig is required to compile zlob
- name: Install Zig
uses: mlugg/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
+3
View File
@@ -10,3 +10,6 @@ result
.repro/
.wrangler/
*.so
big-repo/
# all the perf like utility files
*.data
+4
View File
@@ -0,0 +1,4 @@
empty_config.lua
benches/
doc/
-1
View File
@@ -1,4 +1,3 @@
syntax = "LuaJIT"
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
Generated
+668 -35
View File
File diff suppressed because it is too large Load Diff
+38 -24
View File
@@ -1,39 +1,53 @@
[package]
name = "fff_nvim"
version = "0.1.0"
edition = "2024"
[workspace]
members = [
"crates/fff-c",
"crates/fff-core",
"crates/fff-nvim",
"crates/fff-query-parser",
"crates/fff-searcher",
]
resolver = "2"
[lib]
path = "lua/fff/rust/lib.rs"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "test_watcher"
path = "src/bin/test_watcher.rs"
[[bin]]
name = "jemalloc_profile"
path = "src/bin/jemalloc_profile.rs"
[dependencies]
[workspace.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"
grep-matcher = "0.1.8"
grep-searcher = { path = "crates/fff-searcher" }
heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
zlob = "1.2.9"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.7.1" }
neo_frizbee = "0.8.1"
notify = "8.1.0"
notify-debouncer-mini = "0.7"
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"
regex = "1.11"
smallvec = { version = "1.13", features = ["const_generics", "union"] }
thiserror = "2.0.10"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = true
[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"
+28
View File
@@ -0,0 +1,28 @@
PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-setup
build:
cargo build --release
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 --verbose --workspace --exclude fff-nvim
test-lua: test-setup build
nvim --headless -u tests/minimal_init.lua \
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
test: test-rust test-lua
format-rust:
cargo fmt --all
format-lua:
stylua .
format: format-rust format-lua
+216 -18
View File
@@ -11,8 +11,7 @@
<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.
@@ -70,6 +69,20 @@ 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',
}
}
}
@@ -80,10 +93,10 @@ 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()
if event.data.updated then
require('fff.download').download_or_build_binary()
end
end,
})
@@ -123,6 +136,13 @@ require('fff').setup({
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
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,
@@ -132,7 +152,6 @@ require('fff').setup({
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
wrap_lines = false,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
@@ -145,11 +164,19 @@ require('fff').setup({
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>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
@@ -161,19 +188,74 @@ require('fff').setup({
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
-- 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_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
-- 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, -- 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
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- Live grep search configuration
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
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
}
})
```
@@ -203,18 +285,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
#### 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.
@@ -227,6 +297,134 @@ 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' },
}
})
```
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
#### 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
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "fff-c"
version = "0.1.0"
edition = "2024"
description = "C FFI bindings for fff-core - use from any language with C FFI support"
license = "MIT"
[lib]
crate-type = ["cdylib"]
[dependencies]
mimalloc.workspace = true
once_cell.workspace = true
tracing.workspace = true
git2.workspace = true
fff-core = { path = "../fff-core" }
fff-query-parser = { path = "../fff-query-parser" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+337
View File
@@ -0,0 +1,337 @@
//! FFI-compatible type definitions
//!
//! These types use #[repr(C)] for C ABI compatibility and implement
//! serde traits for JSON serialization.
use std::ffi::{CString, c_char};
use std::ptr;
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
use serde::{Deserialize, Serialize};
/// Result type returned by all FFI functions
/// Returned as a heap-allocated pointer that must be freed with fff_free_result
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded
pub success: bool,
/// JSON data on success (null-terminated string, caller must free)
pub data: *mut c_char,
/// Error message on failure (null-terminated string, caller must free)
pub error: *mut c_char,
}
impl FffResult {
/// Create a successful result with no data, returned as heap pointer
pub fn ok_empty() -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
}))
}
/// Create a successful result with data, returned as heap pointer
pub fn ok_data(data: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: CString::new(data).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
}))
}
/// Create an error result, returned as heap pointer
pub fn err(error: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: false,
data: ptr::null_mut(),
error: CString::new(error).unwrap_or_default().into_raw(),
}))
}
}
/// Initialization options (JSON-deserializable)
#[derive(Debug, Deserialize)]
pub struct InitOptions {
/// Base directory to index (required)
pub base_path: String,
/// Path to frecency database (optional, omit to skip frecency initialization)
pub frecency_db_path: Option<String>,
/// Path to query history database (optional, omit to skip query tracker initialization)
pub history_db_path: Option<String>,
/// Use unsafe no-lock mode for databases (optional, defaults to false)
#[serde(default)]
pub use_unsafe_no_lock: bool,
/// Pre-populate mmap caches for all files after initial scan so the first
/// grep search is as fast as subsequent ones (optional, defaults to false)
#[serde(default)]
pub warmup_mmap_cache: bool,
}
/// Search options (JSON-deserializable)
#[derive(Debug, Default, Deserialize)]
pub struct SearchOptions {
/// Maximum threads for parallel search (0 = auto)
pub max_threads: Option<usize>,
/// Current file path (for deprioritization)
pub current_file: Option<String>,
/// Combo boost score multiplier
pub combo_boost_multiplier: Option<i32>,
/// Minimum combo count for boost
pub min_combo_count: Option<u32>,
/// Page index for pagination
pub page_index: Option<usize>,
/// Page size for pagination
pub page_size: Option<usize>,
}
/// Scan progress (JSON-serializable)
#[derive(Debug, Serialize)]
pub struct ScanProgress {
pub scanned_files_count: usize,
pub is_scanning: bool,
}
/// File item for JSON serialization
#[derive(Debug, Serialize)]
pub struct FileItemJson {
pub path: String,
pub relative_path: String,
pub file_name: 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: String,
pub is_binary: bool,
}
impl FileItemJson {
pub fn from_file_item(item: &FileItem) -> Self {
FileItemJson {
path: item.path.to_string_lossy().to_string(),
relative_path: item.relative_path.clone(),
file_name: item.file_name.clone(),
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,
git_status: format_git_status(item.git_status).to_string(),
is_binary: item.is_binary,
}
}
}
/// Score for JSON serialization
#[derive(Debug, Serialize)]
pub struct ScoreJson {
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: String,
}
impl ScoreJson {
pub fn from_score(score: &Score) -> Self {
ScoreJson {
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: score.match_type.to_string(),
}
}
}
/// Location for JSON serialization
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum LocationJson {
#[serde(rename = "line")]
Line { line: i32 },
#[serde(rename = "position")]
Position { line: i32, col: i32 },
#[serde(rename = "range")]
Range {
start: PositionJson,
end: PositionJson,
},
}
#[derive(Debug, Serialize)]
pub struct PositionJson {
pub line: i32,
pub col: i32,
}
impl LocationJson {
pub fn from_location(loc: &Location) -> Self {
match loc {
Location::Line(line) => LocationJson::Line { line: *line },
Location::Position { line, col } => LocationJson::Position {
line: *line,
col: *col,
},
Location::Range { start, end } => LocationJson::Range {
start: PositionJson {
line: start.0,
col: start.1,
},
end: PositionJson {
line: end.0,
col: end.1,
},
},
}
}
}
/// Search result for JSON serialization
#[derive(Debug, Serialize)]
pub struct SearchResultJson {
pub items: Vec<FileItemJson>,
pub scores: Vec<ScoreJson>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<LocationJson>,
}
impl SearchResultJson {
pub fn from_search_result(result: &SearchResult) -> Self {
SearchResultJson {
items: result
.items
.iter()
.map(|item| FileItemJson::from_file_item(item))
.collect(),
scores: result.scores.iter().map(ScoreJson::from_score).collect(),
total_matched: result.total_matched,
total_files: result.total_files,
location: result.location.as_ref().map(LocationJson::from_location),
}
}
}
// ============================================================================
// Grep (live search) types
// ============================================================================
/// Grep search options (JSON-deserializable)
#[derive(Debug, Default, Deserialize)]
pub struct GrepSearchOptionsJson {
/// Maximum file size to search (bytes, default: 10MB)
pub max_file_size: Option<u64>,
/// Maximum matches per file (default: 200)
pub max_matches_per_file: Option<usize>,
/// Smart case: case-insensitive if query is lowercase (default: true)
pub smart_case: Option<bool>,
/// File-based pagination offset (default: 0)
pub file_offset: Option<usize>,
/// Maximum matches to return (default: 50)
pub page_limit: Option<usize>,
/// Search mode: "plain", "regex", or "fuzzy" (default: "plain")
pub mode: Option<String>,
/// Time budget in milliseconds, 0 = unlimited (default: 0)
pub time_budget_ms: Option<u64>,
}
/// A single grep match for JSON serialization
#[derive(Debug, Serialize)]
pub struct GrepMatchJson {
/// File metadata
pub path: String,
pub relative_path: String,
pub file_name: String,
pub git_status: String,
pub size: u64,
pub modified: u64,
pub is_binary: bool,
pub total_frecency_score: i64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
/// Match metadata
pub line_number: u64,
pub col: usize,
pub byte_offset: u64,
pub line_content: String,
/// Byte offset pairs (start, end) within line_content for highlighting
pub match_ranges: Vec<[u32; 2]>,
/// Fuzzy match score (only in fuzzy mode)
#[serde(skip_serializing_if = "Option::is_none")]
pub fuzzy_score: Option<u16>,
}
impl GrepMatchJson {
pub fn from_grep_match(m: &GrepMatch, file: &FileItem) -> Self {
GrepMatchJson {
path: file.path.to_string_lossy().to_string(),
relative_path: file.relative_path.clone(),
file_name: file.file_name.clone(),
git_status: format_git_status(file.git_status).to_string(),
size: file.size,
modified: file.modified,
is_binary: file.is_binary,
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,
col: m.col,
byte_offset: m.byte_offset,
line_content: m.line_content.clone(),
match_ranges: m
.match_byte_offsets
.iter()
.map(|&(start, end)| [start, end])
.collect(),
fuzzy_score: m.fuzzy_score,
}
}
}
/// Grep result for JSON serialization
#[derive(Debug, Serialize)]
pub struct GrepResultJson {
pub items: Vec<GrepMatchJson>,
pub total_matched: usize,
pub total_files_searched: usize,
pub total_files: usize,
pub filtered_file_count: usize,
pub next_file_offset: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub regex_fallback_error: Option<String>,
}
impl GrepResultJson {
pub fn from_grep_result(result: &GrepResult) -> Self {
GrepResultJson {
items: result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
GrepMatchJson::from_grep_match(m, file)
})
.collect(),
total_matched: result.total_match_count,
total_files_searched: result.total_files_searched,
total_files: result.total_files,
filtered_file_count: result.filtered_file_count,
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.clone(),
}
}
}
+780
View File
@@ -0,0 +1,780 @@
//! C FFI bindings for fff-core
//!
//! This crate provides C-compatible FFI exports that can be used from any language
//! with C FFI support (Bun, Node.js, Python, Ruby, etc.).
//!
//! All functions return a pointer to a heap-allocated `FffResult` struct containing
//! success status and either data (as JSON string) or an error message.
//! Memory must be freed using `fff_free_result`.
use std::ffi::{CStr, CString, c_char};
use std::path::PathBuf;
use std::time::Duration;
mod ffi_types;
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::query_tracker::QueryTracker;
use fff_core::{DbHealthChecker, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
use ffi_types::{FffResult, GrepSearchOptionsJson, InitOptions, ScanProgress, SearchOptions};
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
/// Helper to convert C string to Rust &str.
///
/// Returns `None` if the pointer is null or the string is not valid UTF-8.
/// This is more efficient than `to_string_lossy()` as it returns a borrowed
/// `&str` directly without `Cow` overhead, and avoids replacement character
/// scanning since callers are expected to provide valid UTF-8.
unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
if s.is_null() {
None
} else {
unsafe { CStr::from_ptr(s).to_str().ok() }
}
}
/// Initialize the file finder with the given options (JSON string)
///
/// # Safety
/// `opts_json` must be a valid null-terminated UTF-8 string
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_init(opts_json: *const c_char) -> *mut FffResult {
let opts_str = match unsafe { cstr_to_str(opts_json) } {
Some(s) => s,
None => return FffResult::err("Options JSON is null or invalid UTF-8"),
};
let opts: InitOptions = match serde_json::from_str(opts_str) {
Ok(o) => o,
Err(e) => return FffResult::err(&format!("Failed to parse options: {}", e)),
};
// Initialize frecency tracker if path is provided
if let Some(frecency_path) = opts.frecency_db_path {
// Ensure directory exists
if let Some(parent) = PathBuf::from(&frecency_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut frecency = match FRECENCY.write() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
};
*frecency = None;
match FrecencyTracker::new(&frecency_path, opts.use_unsafe_no_lock) {
Ok(tracker) => *frecency = Some(tracker),
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
}
drop(frecency);
}
// Initialize query tracker if path is provided
if let Some(history_path) = opts.history_db_path {
// Ensure directory exists
if let Some(parent) = PathBuf::from(&history_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut query_tracker = match QUERY_TRACKER.write() {
Ok(q) => q,
Err(e) => {
return FffResult::err(&format!("Failed to acquire query tracker lock: {}", e));
}
};
*query_tracker = None;
match QueryTracker::new(&history_path, opts.use_unsafe_no_lock) {
Ok(tracker) => *query_tracker = Some(tracker),
Err(e) => return FffResult::err(&format!("Failed to init query tracker db: {}", e)),
}
drop(query_tracker);
}
// Initialize file picker
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
if file_picker.is_some() {
// Already initialized, clean up first
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
}
}
match FilePicker::with_options(opts.base_path, opts.warmup_mmap_cache) {
Ok(picker) => {
*file_picker = Some(picker);
FffResult::ok_empty()
}
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
}
}
/// Destroy all resources and clean up
#[unsafe(no_mangle)]
pub extern "C" fn fff_destroy() -> *mut FffResult {
// Clean up file picker
if let Ok(mut file_picker) = FILE_PICKER.write()
&& let Some(mut picker) = file_picker.take()
{
picker.stop_background_monitor();
}
// Clean up frecency
if let Ok(mut frecency) = FRECENCY.write() {
*frecency = None;
}
// Clean up query tracker
if let Ok(mut query_tracker) = QUERY_TRACKER.write() {
*query_tracker = None;
}
FffResult::ok_empty()
}
// ============================================================================
// Search Functions
// ============================================================================
/// Perform fuzzy search on indexed files
///
/// # Safety
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_search(
query: *const c_char,
opts_json: *const c_char,
) -> *mut FffResult {
let query_str = match unsafe { cstr_to_str(query) } {
Some(s) => s,
None => return FffResult::err("Query is null or invalid UTF-8"),
};
let opts: SearchOptions = if opts_json.is_null() {
SearchOptions::default()
} else {
unsafe { cstr_to_str(opts_json) }
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default()
};
let file_picker_guard = match FILE_PICKER.read() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker_guard.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized. Call fff_init first."),
};
let base_path = picker.base_path();
let min_combo_count = opts.min_combo_count.unwrap_or(3);
// Get last same query entry for combo matching
let last_same_query_entry = {
let query_tracker = match QUERY_TRACKER.read() {
Ok(q) => q,
Err(_) => return FffResult::err("Failed to acquire query tracker lock"),
};
query_tracker.as_ref().and_then(|tracker| {
tracker
.get_last_query_entry(query_str, base_path, min_combo_count)
.ok()
.flatten()
})
};
// Parse the query
let parser = QueryParser::default();
let parsed = parser.parse(query_str);
let results = FilePicker::fuzzy_search(
picker.get_files(),
query_str,
parsed,
FuzzySearchOptions {
max_threads: opts.max_threads.unwrap_or(0),
current_file: opts.current_file.as_deref(),
project_path: Some(picker.base_path()),
last_same_query_match: last_same_query_entry.as_ref(),
combo_boost_score_multiplier: opts.combo_boost_multiplier.unwrap_or(100),
min_combo_count,
pagination: PaginationArgs {
offset: opts.page_index.unwrap_or(0),
limit: opts.page_size.unwrap_or(100),
},
},
);
// Convert to JSON
let json_result = ffi_types::SearchResultJson::from_search_result(&results);
match serde_json::to_string(&json_result) {
Ok(json) => FffResult::ok_data(&json),
Err(e) => FffResult::err(&format!("Failed to serialize results: {}", e)),
}
}
/// Perform content search (grep) across indexed files
///
/// Searches file contents using the specified mode:
/// - "plain" (default): SIMD-accelerated literal text matching
/// - "regex": Regular expression matching
/// - "fuzzy": Smith-Waterman fuzzy matching per line
///
/// Results include file metadata and match locations with byte offsets
/// for highlighting. Supports file-based pagination via `file_offset`
/// and `next_file_offset` in the result.
///
/// # Safety
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_live_grep(
query: *const c_char,
opts_json: *const c_char,
) -> *mut FffResult {
let query_str = match unsafe { cstr_to_str(query) } {
Some(s) => s,
None => return FffResult::err("Query is null or invalid UTF-8"),
};
let opts: GrepSearchOptionsJson = if opts_json.is_null() {
GrepSearchOptionsJson::default()
} else {
unsafe { cstr_to_str(opts_json) }
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default()
};
let file_picker_guard = match FILE_PICKER.read() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker_guard.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized. Call fff_init first."),
};
let mode = match opts.mode.as_deref() {
Some("regex") => fff_core::GrepMode::Regex,
Some("fuzzy") => fff_core::GrepMode::Fuzzy,
_ => fff_core::GrepMode::PlainText,
};
let parsed = fff_core::grep::parse_grep_query(query_str);
let options = fff_core::GrepSearchOptions {
max_file_size: opts.max_file_size.unwrap_or(10 * 1024 * 1024),
max_matches_per_file: opts.max_matches_per_file.unwrap_or(200),
smart_case: opts.smart_case.unwrap_or(true),
file_offset: opts.file_offset.unwrap_or(0),
page_limit: opts.page_limit.unwrap_or(50),
mode,
time_budget_ms: opts.time_budget_ms.unwrap_or(0),
};
let result = fff_core::grep::grep_search(picker.get_files(), query_str, parsed, &options);
let json_result = ffi_types::GrepResultJson::from_grep_result(&result);
match serde_json::to_string(&json_result) {
Ok(json) => FffResult::ok_data(&json),
Err(e) => FffResult::err(&format!("Failed to serialize grep results: {}", e)),
}
}
// ============================================================================
// File Index Functions
// ============================================================================
/// Trigger a rescan of the file index
#[unsafe(no_mangle)]
pub extern "C" fn fff_scan_files() -> *mut FffResult {
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker.as_mut() {
Some(p) => p,
None => return FffResult::err("File picker not initialized"),
};
match picker.trigger_rescan() {
Ok(_) => FffResult::ok_empty(),
Err(e) => FffResult::err(&format!("Failed to trigger rescan: {}", e)),
}
}
/// Check if a scan is currently in progress
#[unsafe(no_mangle)]
pub extern "C" fn fff_is_scanning() -> bool {
FILE_PICKER
.read()
.ok()
.and_then(|guard| guard.as_ref().map(|p| p.is_scan_active()))
.unwrap_or(false)
}
/// Get scan progress information
#[unsafe(no_mangle)]
pub extern "C" fn fff_get_scan_progress() -> *mut FffResult {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized"),
};
let progress = picker.get_scan_progress();
let result = ScanProgress {
scanned_files_count: progress.scanned_files_count,
is_scanning: progress.is_scanning,
};
match serde_json::to_string(&result) {
Ok(json) => FffResult::ok_data(&json),
Err(e) => FffResult::err(&format!("Failed to serialize progress: {}", e)),
}
}
/// Wait for initial scan to complete
#[unsafe(no_mangle)]
pub extern "C" fn fff_wait_for_scan(timeout_ms: u64) -> *mut FffResult {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker.as_ref() {
Some(p) => p,
None => return FffResult::err("File picker not initialized"),
};
let timeout = Duration::from_millis(timeout_ms);
let start = std::time::Instant::now();
let mut sleep_duration = Duration::from_millis(1);
while picker.is_scan_active() {
if start.elapsed() >= timeout {
return FffResult::ok_data("false");
}
std::thread::sleep(sleep_duration);
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
}
FffResult::ok_data("true")
}
/// Restart indexing in a new directory
///
/// # Safety
/// `new_path` must be a valid null-terminated UTF-8 string
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffResult {
let path_str = match unsafe { cstr_to_str(new_path) } {
Some(s) => s,
None => return FffResult::err("Path is null or invalid UTF-8"),
};
let path = PathBuf::from(&path_str);
if !path.exists() {
return FffResult::err(&format!("Path does not exist: {}", path_str));
}
let canonical_path = match fff_core::path_utils::canonicalize(&path) {
Ok(p) => p,
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
};
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
// Stop existing picker, preserving warmup setting
let warmup = if let Some(mut picker) = file_picker.take() {
let warmup = picker.warmup_mmap_cache();
picker.stop_background_monitor();
warmup
} else {
false
};
// Create new picker
match FilePicker::with_options(canonical_path.to_string_lossy().to_string(), warmup) {
Ok(picker) => {
*file_picker = Some(picker);
FffResult::ok_empty()
}
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
}
}
// ============================================================================
// Frecency Functions
// ============================================================================
/// Track file access for frecency scoring
///
/// # Safety
/// `file_path` must be a valid null-terminated UTF-8 string
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffResult {
let path_str = match unsafe { cstr_to_str(file_path) } {
Some(s) => s,
None => return FffResult::err("File path is null or invalid UTF-8"),
};
let file_path = PathBuf::from(&path_str);
// Track in frecency DB
let frecency_guard = match FRECENCY.read() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
};
let frecency = match frecency_guard.as_ref() {
Some(f) => f,
None => return FffResult::ok_data("false"), // Frecency not initialized, skip
};
if let Err(e) = frecency.track_access(&file_path) {
return FffResult::err(&format!("Failed to track access: {}", e));
}
drop(frecency_guard);
// Update in file picker
let mut file_picker = match FILE_PICKER.write() {
Ok(f) => f,
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
};
let picker = match file_picker.as_mut() {
Some(p) => p,
None => return FffResult::ok_data("false"),
};
let frecency_guard = match FRECENCY.read() {
Ok(f) => f,
Err(_) => return FffResult::ok_data("false"),
};
if let Some(ref frecency) = *frecency_guard {
let _ = picker.update_single_file_frecency(&file_path, frecency);
}
FffResult::ok_data("true")
}
// ============================================================================
// Git Functions
// ============================================================================
/// Refresh git status cache
#[unsafe(no_mangle)]
pub extern "C" fn fff_refresh_git_status() -> *mut FffResult {
match FilePicker::refresh_git_status_global() {
Ok(count) => FffResult::ok_data(&count.to_string()),
Err(e) => FffResult::err(&format!("Failed to refresh git status: {}", e)),
}
}
// ============================================================================
// Query Tracking Functions
// ============================================================================
/// Track query completion for smart suggestions
///
/// # Safety
/// `query` and `file_path` must be valid null-terminated UTF-8 strings
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_track_query(
query: *const c_char,
file_path: *const c_char,
) -> *mut FffResult {
let query_str = match unsafe { cstr_to_str(query) } {
Some(s) => s,
None => return FffResult::err("Query is null or invalid UTF-8"),
};
let path_str = match unsafe { cstr_to_str(file_path) } {
Some(s) => s,
None => return FffResult::err("File path is null or invalid UTF-8"),
};
let file_path = match fff_core::path_utils::canonicalize(path_str) {
Ok(p) => p,
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
};
let project_path = {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
Err(_) => return FffResult::ok_data("false"),
};
match file_picker.as_ref() {
Some(p) => p.base_path().to_path_buf(),
None => return FffResult::ok_data("false"),
}
};
let mut query_tracker = match QUERY_TRACKER.write() {
Ok(q) => q,
Err(_) => return FffResult::ok_data("false"),
};
if let Some(ref mut tracker) = *query_tracker
&& let Err(e) = tracker.track_query_completion(query_str, &project_path, &file_path)
{
return FffResult::err(&format!("Failed to track query: {}", e));
}
FffResult::ok_data("true")
}
/// Get historical query by offset (0 = most recent)
#[unsafe(no_mangle)]
pub extern "C" fn fff_get_historical_query(offset: u64) -> *mut FffResult {
let project_path = {
let file_picker = match FILE_PICKER.read() {
Ok(f) => f,
Err(_) => return FffResult::ok_data("null"),
};
match file_picker.as_ref() {
Some(p) => p.base_path().to_path_buf(),
None => return FffResult::ok_data("null"),
}
};
let query_tracker = match QUERY_TRACKER.read() {
Ok(q) => q,
Err(_) => return FffResult::ok_data("null"),
};
let tracker = match query_tracker.as_ref() {
Some(t) => t,
None => return FffResult::ok_data("null"),
};
match tracker.get_historical_query(&project_path, offset as usize) {
Ok(Some(query)) => {
let json = serde_json::to_string(&query).unwrap_or_else(|_| "null".to_string());
FffResult::ok_data(&json)
}
Ok(None) => FffResult::ok_data("null"),
Err(e) => FffResult::err(&format!("Failed to get historical query: {}", e)),
}
}
/// Get health check information
///
/// # Safety
/// `test_path` can be null or a valid null-terminated UTF-8 string
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffResult {
let test_path = unsafe { cstr_to_str(test_path) }
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let mut health = serde_json::Map::new();
health.insert(
"version".to_string(),
serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
);
// Git info
let mut git_info = serde_json::Map::new();
let git_version = git2::Version::get();
let (major, minor, rev) = git_version.libgit2_version();
git_info.insert(
"libgit2_version".to_string(),
serde_json::Value::String(format!("{}.{}.{}", major, minor, rev)),
);
match git2::Repository::discover(&test_path) {
Ok(repo) => {
git_info.insert("available".to_string(), serde_json::Value::Bool(true));
git_info.insert(
"repository_found".to_string(),
serde_json::Value::Bool(true),
);
if let Some(workdir) = repo.workdir() {
git_info.insert(
"workdir".to_string(),
serde_json::Value::String(workdir.to_string_lossy().to_string()),
);
}
}
Err(e) => {
git_info.insert("available".to_string(), serde_json::Value::Bool(true));
git_info.insert(
"repository_found".to_string(),
serde_json::Value::Bool(false),
);
git_info.insert(
"error".to_string(),
serde_json::Value::String(e.message().to_string()),
);
}
}
health.insert("git".to_string(), serde_json::Value::Object(git_info));
// File picker info
let mut picker_info = serde_json::Map::new();
match FILE_PICKER.read() {
Ok(guard) => {
if let Some(ref picker) = *guard {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(true));
picker_info.insert(
"base_path".to_string(),
serde_json::Value::String(picker.base_path().to_string_lossy().to_string()),
);
picker_info.insert(
"is_scanning".to_string(),
serde_json::Value::Bool(picker.is_scan_active()),
);
let progress = picker.get_scan_progress();
picker_info.insert(
"indexed_files".to_string(),
serde_json::Value::Number(progress.scanned_files_count.into()),
);
} else {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
}
Err(_) => {
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
picker_info.insert(
"error".to_string(),
serde_json::Value::String("Failed to acquire lock".to_string()),
);
}
}
health.insert(
"file_picker".to_string(),
serde_json::Value::Object(picker_info),
);
// Frecency info
let mut frecency_info = serde_json::Map::new();
match FRECENCY.read() {
Ok(guard) => {
frecency_info.insert(
"initialized".to_string(),
serde_json::Value::Bool(guard.is_some()),
);
if let Some(ref frecency) = *guard
&& let Ok(health_data) = frecency.get_health()
{
let mut db_health = serde_json::Map::new();
db_health.insert(
"path".to_string(),
serde_json::Value::String(health_data.path),
);
db_health.insert(
"disk_size".to_string(),
serde_json::Value::Number(health_data.disk_size.into()),
);
frecency_info.insert(
"db_healthcheck".to_string(),
serde_json::Value::Object(db_health),
);
}
}
Err(_) => {
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
}
health.insert(
"frecency".to_string(),
serde_json::Value::Object(frecency_info),
);
// Query tracker info
let mut query_info = serde_json::Map::new();
match QUERY_TRACKER.read() {
Ok(guard) => {
query_info.insert(
"initialized".to_string(),
serde_json::Value::Bool(guard.is_some()),
);
if let Some(ref tracker) = *guard
&& let Ok(health_data) = tracker.get_health()
{
let mut db_health = serde_json::Map::new();
db_health.insert(
"path".to_string(),
serde_json::Value::String(health_data.path),
);
db_health.insert(
"disk_size".to_string(),
serde_json::Value::Number(health_data.disk_size.into()),
);
query_info.insert(
"db_healthcheck".to_string(),
serde_json::Value::Object(db_health),
);
}
}
Err(_) => {
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
}
}
health.insert(
"query_tracker".to_string(),
serde_json::Value::Object(query_info),
);
match serde_json::to_string(&health) {
Ok(json) => FffResult::ok_data(&json),
Err(e) => FffResult::err(&format!("Failed to serialize health check: {}", e)),
}
}
/// Free a result returned by any fff_* function
///
/// # Safety
/// `result_ptr` must be a valid pointer returned by a fff_* function
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
if result_ptr.is_null() {
return;
}
unsafe {
let result = Box::from_raw(result_ptr);
if !result.data.is_null() {
drop(CString::from_raw(result.data));
}
if !result.error.is_null() {
drop(CString::from_raw(result.error));
}
// Box will be dropped here, freeing the FffResult struct
}
}
/// Free a string returned by fff_* functions
///
/// # Safety
/// `s` must be a valid C string allocated by this library
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_free_string(s: *mut c_char) {
unsafe {
if !s.is_null() {
drop(CString::from_raw(s));
}
}
}
+65
View File
@@ -0,0 +1,65 @@
[package]
name = "fff-core"
version = "0.1.0"
edition = "2024"
description = "High-performance file finder core library"
license = "MIT"
[lib]
path = "src/lib.rs"
crate-type = ["rlib", "staticlib", "cdylib"]
[features]
default = []
# Enable C FFI exports
ffi = []
[dependencies]
# Workspace dependencies
ahash = { workspace = true }
rayon = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff-query-parser = { path = "../fff-query-parser" }
# External dependencies
bindet = { workspace = true }
blake3 = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
git2 = { workspace = true }
glidesort = { workspace = true }
grep-matcher = { workspace = true }
grep-searcher = { workspace = true }
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"] }
smartstring = { version = "1.0.1", features = ["serde"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true }
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
# 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"
+16
View File
@@ -0,0 +1,16 @@
fn main() {
// On Windows MSVC, explicitly link the C runtime libraries.
// This is needed because Zig-compiled static libraries (zlob) don't emit
// /DEFAULTLIB directives for the MSVC CRT. Without this, symbols like
// strcmp, memcpy, memchr etc. from vendored C libraries (libgit2, lmdb)
// are unresolved when linking the cdylib.
//
// We link both msvcrt (classic CRT) and ucrt (Universal CRT where memchr,
// strcmp etc. live on newer MSVC/ARM64 targets).
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
}
}
+411
View File
@@ -0,0 +1,411 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::git::GitStatusCache;
use crate::sort_buffer::sort_with_buffer;
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify_debouncer_full::{
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{Level, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
const MAX_PATHS_THRESHOLD: usize = 1024;
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
impl BackgroundWatcher {
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}",
base_path.display()
);
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
info!("Background file watcher initialized successfully");
Ok(Self {
debouncer: Arc::new(Mutex::new(Some(debouncer))),
})
}
fn create_debouncer(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
) -> 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 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);
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
},
RecommendedCache::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);
}
}
}
}
info!(
"File watcher initialized for {} directories under {}",
watch_dirs.len(),
base_path.display()
);
Ok(debouncer)
}
pub fn stop(&self) {
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
drop(debouncer);
info!("Background file watcher stopped successfully");
} else {
error!("Failed to stop background watcher");
}
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
if let Some(debouncer) = debouncer_guard.take() {
drop(debouncer);
}
} else {
error!("Failed to acquire debouncer lock to drop");
}
}
}
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
// 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 !should_include_file(path, &repo) {
continue;
}
if !path.exists() {
paths_to_remove.push(path.as_path());
} else {
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();
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()
);
let Some(repo) = repo.as_ref() else {
info!("No git repo, skipping git status updates");
return;
};
if need_full_git_rescan {
info!("Triggering full git rescan");
if let Err(e) = FilePicker::refresh_git_status_global() {
error!("Failed to refresh git status: {:?}", e);
}
return;
}
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
return;
}
let files_to_update_git_status = {
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
// Apply file removals
for path in paths_to_remove {
picker.remove_file_by_path(path);
// No need to invalidate mmap — the FileItem (and its mmap) is dropped
}
// Apply file additions/modifications and collect paths for git status update
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
for path in paths_to_add_or_modify {
// on_create_or_modify clears the mmap internally when modified time changes
if let Some(file) = picker.on_create_or_modify(path) {
files_to_update_git_status.push(file.path.clone());
}
}
files_to_update_git_status
};
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git statue");
return;
}
};
// only lock the picker for theshortest possitble time
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
} else {
info!("Successfully updated git statuses in picker");
}
} else {
error!("Failed to acquire picker lock for git status update");
}
}
fn trigger_full_rescan() {
info!("Triggering full filesystem rescan");
// Note: no need to clear mmaps — they are backed by the kernel page cache
// and automatically reflect file changes. Old FileItems (and their mmaps)
// are dropped when the picker rebuilds its file list.
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock for full rescan");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized, cannot trigger rescan");
return;
};
if let Err(e) = picker.trigger_rescan() {
error!("Failed to trigger full rescan: {:?}", e);
} else {
info!("Full filesystem rescan completed successfully");
}
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
if !path.is_file() || is_git_file(path) {
return false;
}
repo.as_ref()
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
}
#[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")
)
}
/// 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
}
+319
View File
@@ -0,0 +1,319 @@
//! 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 zlob::{ZlobFlags, zlob_match_paths};
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 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)
#[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
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::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) => {
if let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) {
let matched_set: AHashSet<usize> =
matches.iter().map(|s| s.as_ptr() as usize).collect();
let indices: AHashSet<usize> = if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
};
results.push((is_negated, indices));
} else {
results.push((is_negated, AHashSet::new()));
}
}
Constraint::Not(inner) => {
collect_glob_indices(inner, paths, results, !is_negated);
}
_ => {}
}
}
#[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"));
// Edge cases
assert!(!path_contains_segment("", "src"));
assert!(!path_contains_segment("src", "src")); // no trailing slash
}
}
+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,
})
}
}
@@ -1,3 +1,5 @@
use std::path::StripPrefixError;
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum Error {
@@ -11,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}")]
@@ -33,13 +37,12 @@ pub enum Error {
DbCommit(#[source] heed::Error),
#[error("Failed to start file system watcher: {0}")]
FileSystemWatch(#[from] notify::Error),
#[error("Expected a path to be child of another path: {0}")]
StripPrefixError(#[from] StripPrefixError),
#[error("libgit2 error occurred: {0}")]
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>;
@@ -2,21 +2,54 @@ use crate::background_watcher::BackgroundWatcher;
use crate::error::Error;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
use crate::location::parse_location;
use crate::query_tracker::QueryMatchEntry;
use crate::score::match_and_score_files;
use crate::types::{FileItem, ScoringContext, SearchResult};
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
use fff_query_parser::FFFQuery;
use git2::{Repository, Status, StatusOptions};
use rayon::prelude::*;
use std::fmt::Debug;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::SystemTime;
use tracing::{debug, error, info, warn};
use tracing::{Level, debug, error, info, warn};
use crate::{FILE_PICKER, FRECENCY};
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
#[inline]
fn detect_binary(path: &Path, size: u64) -> bool {
// Empty files are not binary
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)
}
#[derive(Debug, Clone, Copy)]
pub struct FuzzySearchOptions<'a> {
pub max_threads: usize,
pub current_file: Option<&'a str>,
pub project_path: Option<&'a Path>,
pub last_same_query_match: Option<&'a QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
}
#[derive(Debug, Clone)]
struct FileSync {
pub files: Vec<FileItem>,
@@ -33,7 +66,7 @@ impl FileSync {
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
self.files
.binary_search_by(|file| file.path.as_path().cmp(path))
.binary_search_by(|file| file.path.as_os_str().cmp(path.as_os_str()))
}
}
@@ -64,17 +97,17 @@ impl FileItem {
Err(_) => (0, 0),
};
Self {
let is_binary = detect_binary(&path, size);
Self::new_raw(
path,
relative_path,
file_name: name,
name,
size,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status,
}
is_binary,
)
}
pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> {
@@ -103,6 +136,7 @@ pub struct FilePicker {
is_scanning: Arc<AtomicBool>,
scanned_files_count: Arc<AtomicUsize>,
background_watcher: Option<BackgroundWatcher>,
warmup_mmap_cache: bool,
}
impl std::fmt::Debug for FilePicker {
@@ -120,6 +154,14 @@ impl std::fmt::Debug for FilePicker {
}
impl FilePicker {
pub fn base_path(&self) -> &Path {
&self.base_path
}
pub fn warmup_mmap_cache(&self) -> bool {
self.warmup_mmap_cache
}
pub fn git_root(&self) -> Option<&Path> {
self.sync_data.git_workdir.as_deref()
}
@@ -129,7 +171,20 @@ impl FilePicker {
}
pub fn new(base_path: String) -> Result<Self, Error> {
info!("Initializing FilePicker with base_path: {}", base_path);
Self::with_options(base_path, false)
}
/// Create a new FilePicker with explicit options.
///
/// When `warmup_mmap_cache` is `true`, all non-binary files will be mmap'd
/// and their pages paged in immediately after the initial scan completes.
/// This makes the first grep search as fast as subsequent ones at the cost
/// of a longer startup time and higher initial memory pressure.
pub fn with_options(base_path: String, warmup_mmap_cache: bool) -> Result<Self, Error> {
info!(
"Initializing FilePicker with base_path: {}, warmup: {}",
base_path, warmup_mmap_cache
);
let path = PathBuf::from(&base_path);
if !path.exists() {
error!("Base path does not exist: {}", base_path);
@@ -145,55 +200,89 @@ impl FilePicker {
is_scanning: Arc::clone(&scan_signal),
scanned_files_count: Arc::clone(&synced_files_count),
background_watcher: None,
warmup_mmap_cache,
};
spawn_scan_and_watcher(
path.clone(),
Arc::clone(&scan_signal),
Arc::clone(&synced_files_count),
warmup_mmap_cache,
);
Ok(picker)
}
/// Perform fuzzy search on files with a pre-parsed query.
///
/// The query should be parsed using `QueryParser::parse()` before calling this function.
/// This allows the caller to handle location parsing and other preprocessing.
///
/// # Arguments
/// * `files` - Slice of files to search
/// * `query` - The raw query string (used for max_typos calculation and debugging)
/// * `parsed` - Pre-parsed query result (can be None for simple single-token queries)
/// * `options` - Search options including pagination, threading, and scoring parameters
///
/// # Returns
/// SearchResult containing matched files, scores, and location information
pub fn fuzzy_search<'a>(
files: &'a [FileItem],
query: &'a str,
max_results: usize,
max_threads: usize,
current_file: Option<&'a str>,
reverse_order: bool,
parsed: Option<FFFQuery<'a>>,
options: FuzzySearchOptions<'a>,
) -> SearchResult<'a> {
let max_threads = max_threads.max(1);
let max_threads = options.max_threads.max(1);
debug!(
?query,
?max_results,
parsed_is_some = parsed.is_some(),
pagination = ?options.pagination,
?max_threads,
?current_file,
current_file = ?options.current_file,
"Fuzzy search",
);
let total_files = files.len();
let (query, location) = parse_location(query);
// Extract location from parsed query
let location = parsed.as_ref().and_then(|p| p.location);
// Get effective query for max_typos calculation (without location suffix)
let effective_query = match &parsed {
Some(p) => match &p.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query.trim(),
},
None => query.trim(),
};
// small queries with a large number of results can match absolutely everything
let max_typos = (query.len() as u16 / 4).clamp(2, 6);
let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
let context = ScoringContext {
query,
raw_query: query,
parsed_query: parsed,
project_path: options.project_path,
max_typos,
max_threads,
current_file,
max_results,
reverse_order,
current_file: options.current_file,
last_same_query_match: options.last_same_query_match,
combo_boost_score_multiplier: options.combo_boost_score_multiplier,
min_combo_count: options.min_combo_count,
pagination: options.pagination,
};
let time = std::time::Instant::now();
let (items, scores, total_matched) = match_and_score_files(files, &context);
debug!(
?query,
completed_in = ?time.elapsed(),
top_position = ?items.first(),
total_matched,
returned_count = items.len(),
pagination = ?options.pagination,
"Fuzzy search completed",
);
@@ -215,14 +304,7 @@ impl FilePicker {
}
}
pub fn update_git_statuses(
&mut self,
status_cache: Option<GitStatusCache>,
) -> Result<(), Error> {
let Some(status_cache) = status_cache else {
return Ok(());
};
pub fn update_git_statuses(&mut self, status_cache: GitStatusCache) -> Result<(), Error> {
debug!(
statuses_count = status_cache.statuses_len(),
"Updating git status",
@@ -238,6 +320,8 @@ impl FilePicker {
if let Some(frecency) = frecency.as_ref() {
file.update_frecency_scores(frecency)?;
}
} else {
error!(?path, "Couldn't update the git status for path");
}
Ok(())
@@ -278,8 +362,14 @@ impl FilePicker {
.as_mut()
.ok_or_else(|| Error::FilePickerMissing)?;
let statuses_count = git_status.as_ref().map_or(0, |cache| cache.statuses_len());
picker.update_git_statuses(git_status)?;
let statuses_count = if let Some(git_status) = git_status {
let count = git_status.statuses_len();
picker.update_git_statuses(git_status)?;
count
} else {
0
};
Ok(statuses_count)
}
@@ -334,7 +424,8 @@ impl FilePicker {
}
}
pub fn on_create_or_modify(&mut self, path: impl AsRef<Path>) -> Option<&FileItem> {
#[tracing::instrument(skip(self), name = "timing_update", level = Level::DEBUG)]
pub fn on_create_or_modify(&mut self, path: impl AsRef<Path> + Debug) -> Option<&FileItem> {
let path = path.as_ref();
match self.sync_data.find_file_index(path) {
Ok(pos) => {
@@ -356,6 +447,12 @@ impl FilePicker {
let modified = modified.as_secs();
if file.modified < modified {
file.modified = modified;
// TODO figure out if we actually need to remap the memory or invalidate
// mapping here because on linux and macos with the shared map opening it
// should be automatically available everywhere automatically which saves
// some time from doing extra remapping on every search
file.invalidate_mmap();
}
}
@@ -407,14 +504,21 @@ impl FilePicker {
self.is_scanning.store(true, Ordering::Relaxed);
self.scanned_files_count.store(0, Ordering::Relaxed);
if let Ok(sync) = scan_filesystem(&self.base_path, &self.scanned_files_count) {
info!(
"Filesystem scan completed: found {} files",
sync.files.len()
);
self.sync_data = sync
} else {
warn!("Filesystem scan failed");
let scan_result = scan_filesystem(&self.base_path, &self.scanned_files_count);
match scan_result {
Ok(sync) => {
info!(
"Filesystem scan completed: found {} files",
sync.files.len()
);
self.sync_data = sync;
if self.warmup_mmap_cache {
warmup_mmaps(&self.sync_data.files);
}
}
Err(error) => error!(?error, "Failed to scan file system"),
}
self.is_scanning.store(false, Ordering::Relaxed);
@@ -437,6 +541,7 @@ fn spawn_scan_and_watcher(
base_path: PathBuf,
scan_signal: Arc<AtomicBool>,
synced_files_count: Arc<AtomicUsize>,
warmup_mmap_cache: bool,
) {
std::thread::spawn(move || {
scan_signal.store(true, Ordering::Relaxed);
@@ -455,6 +560,10 @@ fn spawn_scan_and_watcher(
&& let Some(ref mut picker) = *file_picker_guard
{
picker.sync_data = sync;
if warmup_mmap_cache {
warmup_mmaps(&picker.sync_data.files);
}
}
}
Err(e) => {
@@ -482,6 +591,38 @@ fn spawn_scan_and_watcher(
});
}
/// Pre-populate mmap caches for all eligible files so the first grep search
/// doesn't pay the mmap creation + page fault cost.
///
/// Each file is mmap'd and a single byte is read to trigger the page fault.
/// This runs in parallel using rayon.
fn warmup_mmaps(files: &[FileItem]) {
let warmup_start = std::time::Instant::now();
let warmed = std::sync::atomic::AtomicUsize::new(0);
files.par_iter().for_each(|file| {
if file.is_binary || file.size == 0 {
return;
}
if let Some(mmap) = file.get_mmap() {
// Read the first byte to trigger the initial page fault, which
// causes the kernel to start readahead for subsequent pages.
// This is cheaper than madvise and portable across all platforms.
let _ = std::hint::black_box(mmap.first());
warmed.fetch_add(1, Ordering::Relaxed);
}
});
let warmed_count = warmed.load(Ordering::Relaxed);
info!(
"Mmap warmup completed: {warmed_count}/{} files in {:?}",
files.len(),
warmup_start.elapsed()
);
}
fn scan_filesystem(
base_path: &Path,
synced_files_count: &Arc<AtomicUsize>,
@@ -515,6 +656,7 @@ fn scan_filesystem(
.recurse_untracked_dirs(true)
.exclude_submodules(true),
);
(git_workdir, status_cache)
});
@@ -592,7 +734,8 @@ fn scan_filesystem(
files.len()
);
files.par_sort_unstable_by(|a, b| a.path.cmp(&b.path));
// Sort by OsStr instead of Path to avoid expensive component-by-component comparison
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
Ok(FileSync { files, git_workdir })
})
}
@@ -1,3 +1,4 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::{error::Error, git::is_modified_status};
use heed::{Database, Env, EnvOpenOptions};
use heed::{
@@ -26,6 +27,19 @@ const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(1, 60 * 60 * 24 * 7), // 1 week
];
impl DbHealthChecker for FrecencyTracker {
fn get_env(&self) -> &heed::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 = self.db.len(&rtxn).map_err(Error::DbRead)?;
Ok(vec![("absolute_frecency_entries", count)])
}
}
impl FrecencyTracker {
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
@@ -1,9 +1,10 @@
use crate::error::Result;
use git2::{Repository, Status, StatusOptions};
use std::{
fmt::Debug,
path::{Path, PathBuf},
};
use tracing::{debug, error, info};
use tracing::debug;
/// Represents a cache of a single git status query, if there is no
/// status aka file is clear but it was specifically requested to updated
@@ -32,19 +33,12 @@ impl GitStatusCache {
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
}
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Option<Self> {
let status_start = std::time::Instant::now();
info!("GIT: Reading git status");
let statuses = repo
.statuses(Some(status_options))
.map_err(|e| {
error!("Failed to get git statuses: {}", e);
e
})
.ok()?;
let status_time = status_start.elapsed();
let repo_path = repo.path().parent()?;
info!("GIT: Status query completed in {:?}", status_time);
#[tracing::instrument(skip(repo, status_options))]
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
let statuses = repo.statuses(Some(status_options))?;
let Some(repo_path) = repo.workdir() else {
return Ok(Self(vec![])); // repo is bare
};
let mut entries = Vec::with_capacity(statuses.len());
for entry in &statuses {
@@ -54,7 +48,7 @@ impl GitStatusCache {
}
}
Some(Self(entries))
Ok(Self(entries))
}
pub fn read_git_status(
@@ -64,20 +58,42 @@ impl GitStatusCache {
let git_workdir = git_workdir.as_ref()?;
let repository = Repository::open(git_workdir).ok()?;
Self::read_status_impl(&repository, status_options)
let status = Self::read_status_impl(&repository, status_options);
match status {
Ok(status) => Some(status),
Err(e) => {
tracing::error!(?e, "Failed to read git status");
None
}
}
}
#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
pub fn git_status_for_paths<TPath: AsRef<Path> + Debug>(
repo: &Repository,
paths: &[TPath],
) -> Option<Self> {
) -> Result<Self> {
if paths.is_empty() {
return None;
return Ok(Self(vec![]));
}
debug!(?paths, "Git partial git status for paths");
let mut status_options = StatusOptions::new();
let Some(workdir) = repo.workdir() else {
return Ok(Self(vec![]));
};
// git pathspec is pretty slow and requires to walk the whole directory
// so for a single file which is the most general use case we query directly the file
if paths.len() == 1 {
let full_path = paths[0].as_ref();
let relative_path = full_path.strip_prefix(workdir)?;
let status = repo.status_file(relative_path)?;
return Ok(Self(vec![(full_path.to_path_buf(), status)]));
}
let mut status_options = StatusOptions::new();
status_options
.include_untracked(true)
.recurse_untracked_dirs(true)
@@ -85,17 +101,16 @@ impl GitStatusCache {
.include_unmodified(true);
for path in paths {
status_options.pathspec(path.as_ref());
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
}
let statuses = Self::read_status_impl(repo, &mut status_options)?;
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
debug!(
"Git partial status for paths {:?} returned {} entries",
statuses,
statuses.statuses_len()
status_len = git_status_cache.statuses_len(),
"Multiple files git status"
);
Some(statuses)
Ok(git_status_cache)
}
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
//! fff-core - High-performance file finder library
//!
//! This crate provides the core file indexing and fuzzy search functionality.
//! It maintains global state for the file picker, frecency tracker, and query tracker.
mod background_watcher;
pub mod constraints;
mod db_healthcheck;
mod error;
pub mod file_picker;
pub mod frecency;
pub mod git;
pub mod grep;
pub mod path_utils;
pub mod query_tracker;
pub mod score;
mod sort_buffer;
pub mod types;
use file_picker::FilePicker;
use frecency::FrecencyTracker;
use once_cell::sync::Lazy;
use query_tracker::QueryTracker;
use std::sync::RwLock;
// Global state - same pattern as fff-nvim
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
// Re-export main types for convenience
pub use db_healthcheck::{DbHealth, DbHealthChecker};
pub use error::{Error, Result};
pub use file_picker::{FuzzySearchOptions, ScanProgress};
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
// Re-export grep types
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
// Re-export query parser types (including Location which moved there)
pub use fff_query_parser::{
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
};
@@ -1,3 +1,24 @@
//! Path utility functions for file picker scoring
use std::path::{Path, PathBuf};
/// Canonicalize a path, resolving symlinks and producing an absolute path.
///
/// On Windows, uses `dunce::canonicalize` to avoid the `\\?\` extended-length path prefix
/// that `std::fs::canonicalize` produces. Neovim cannot open paths with this prefix.
/// On other platforms, delegates directly to `std::fs::canonicalize`.
#[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)
}
/// 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
+413
View File
@@ -0,0 +1,413 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::error::Error;
use heed::types::Bytes;
use heed::{Database, Env, EnvOpenOptions};
use heed::{EnvFlags, types::SerdeBincode};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const MAX_HISTORY_ENTRIES: usize = 128;
/// Simplified QueryFileEntry without redundant fields
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QueryMatchEntry {
pub file_path: PathBuf, // File that was actually opened
pub open_count: u32, // Number of times opened with this query
pub last_opened: u64, // Unix timestamp
}
/// Entry for query history tracking
#[derive(Debug, Serialize, Deserialize, Clone)]
struct HistoryEntry {
query: String,
timestamp: u64,
}
#[derive(Debug)]
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 (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> {
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let env = unsafe {
let mut opts = EnvOpenOptions::new();
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);
}
opts.open(db_path).map_err(Error::EnvOpen)?
};
env.clear_stale_readers()
.map_err(Error::DbClearStaleReaders)?;
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
// Create two named databases
let query_file_db = env
.create_database(&mut wtxn, Some("query_file_associations"))
.map_err(Error::DbCreate)?;
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)?;
Ok(QueryTracker {
env,
query_file_db,
query_history_db,
grep_query_history_db,
})
}
fn get_now(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn create_query_key(project_path: &Path, query: &str) -> Result<[u8; 32], Error> {
let project_str = project_path
.to_str()
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
let mut hasher = blake3::Hasher::default();
hasher.update(project_str.as_bytes());
hasher.update(b"::");
hasher.update(query.as_bytes());
Ok(*hasher.finalize().as_bytes())
}
fn create_project_key(project_path: &Path) -> Result<[u8; 32], Error> {
let project_str = project_path
.to_str()
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
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,
project_path: &Path,
file_path: &Path,
) -> Result<(), Error> {
let now = self.get_now();
let file_path_buf = file_path.to_path_buf();
let query_key = Self::create_query_key(project_path, query)?;
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
let mut entry = self
.query_file_db
.get(&wtxn, &query_key)
.map_err(Error::DbRead)?
.unwrap_or_else(|| QueryMatchEntry {
file_path: file_path_buf.clone(),
open_count: 0,
last_opened: now,
});
if entry.file_path == file_path_buf {
tracing::debug!(
?query,
?file_path,
"Query completed for same file as last time"
);
// Same file - just increment count
entry.open_count += 1;
} else {
tracing::debug!(
?query,
?file_path,
"Query completed for different file than last time"
);
// Different file - replace and reset count to 1
entry.file_path = file_path_buf;
entry.open_count = 1;
}
entry.last_opened = now;
self.query_file_db
.put(&mut wtxn, &query_key, &entry)
.map_err(Error::DbWrite)?;
// Update query history database
let project_key = Self::create_project_key(project_path)?;
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)?;
wtxn.commit().map_err(Error::DbCommit)?;
tracing::debug!(?query, ?file_path, "Tracked query completion");
Ok(())
}
pub fn get_last_query_entry(
&self,
query: &str,
project_path: &Path,
min_combo_count: u32,
) -> Result<Option<QueryMatchEntry>, Error> {
let query_key = Self::create_query_key(project_path, query)?;
tracing::debug!(?query_key, "HASH");
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let last_match = self
.query_file_db
.get(&rtxn, &query_key)
.map_err(Error::DbRead)?;
Ok(last_match.filter(|entry| entry.open_count >= min_combo_count))
}
pub fn get_last_query_path(
&self,
query: &str,
project_path: &Path,
file_path: &Path,
combo_boost: i32,
) -> Result<i32, Error> {
let query_key = Self::create_query_key(project_path, query)?;
tracing::debug!(?query_key, "HASH");
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
match self
.query_file_db
.get(&rtxn, &query_key)
.map_err(Error::DbRead)?
{
Some(entry) => {
// Check if the file path matches and return boost
if entry.file_path == file_path && entry.open_count >= 2 {
Ok(combo_boost)
} else {
Ok(0)
}
}
None => Ok(0), // Query not found
}
}
/// Get query from file picker history at a specific offset.
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
pub fn get_historical_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.query_history_db, &self.env, &project_key, offset)
}
/// 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)?;
Self::append_to_history(
&self.grep_query_history_db,
&mut wtxn,
&project_key,
query,
now,
)?;
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_query_tracking() {
let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
let _ = std::fs::remove_dir_all(&temp_dir);
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
let project_path = PathBuf::from("/test/project");
let file_path = PathBuf::from("/test/project/src/main.rs");
// First completion
tracker
.track_query_completion("main", &project_path, &file_path)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 0, "First completion should not boost");
// Second completion - should boost now
tracker
.track_query_completion("main", &project_path, &file_path)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 10000, "Second completion should boost");
// Different file for same query - should reset count and no boost
let other_file = PathBuf::from("/test/project/src/lib.rs");
tracker
.track_query_completion("main", &project_path, &other_file)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &other_file, 10000)
.unwrap();
assert_eq!(boost, 0, "Different file should reset boost");
// Original file should no longer get boost (replaced by new file)
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 0, "Original file should not boost after replacement");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_hashing_functions() {
let project_path = PathBuf::from("/test/project");
// Test project key hashing
let key1 = QueryTracker::create_project_key(&project_path).unwrap();
let key2 = QueryTracker::create_project_key(&project_path).unwrap();
assert_eq!(key1, key2, "Same project should hash to same key");
// Test query key hashing
let query_key1 = QueryTracker::create_query_key(&project_path, "test").unwrap();
let query_key2 = QueryTracker::create_query_key(&project_path, "test").unwrap();
assert_eq!(
query_key1, query_key2,
"Same project+query should hash to same key"
);
// Different queries should hash differently
let query_key3 = QueryTracker::create_query_key(&project_path, "different").unwrap();
assert_ne!(
query_key1, query_key3,
"Different queries should hash to different keys"
);
// Different projects should hash differently
let other_project = PathBuf::from("/other/project");
let query_key4 = QueryTracker::create_query_key(&other_project, "test").unwrap();
assert_ne!(
query_key1, query_key4,
"Different projects should hash to different keys"
);
}
}
+720
View File
@@ -0,0 +1,720 @@
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.parsed_query;
let working_files: FileItems<'a> = match parsed.as_ref().and_then(|p| {
if p.constraints.is_empty() {
None
} else {
apply_constraints(files, &p.constraints)
}
}) {
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
Some(_) => {
return (vec![], vec![], 0);
}
None => FileItems::All(files),
};
let query_trimmed: &str = context.raw_query.trim();
let single_part_storage: [&str; 1] = [query_trimmed];
let fuzzy_parts: &[&str] = match parsed {
None => {
tracing::debug!("STEP 3: Query too short (<2 chars), returning frecency-sorted");
if query_trimmed.len() < 2 {
return score_filtered_by_frecency(&working_files, context);
}
&single_part_storage
}
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
_ => {
return score_filtered_by_frecency(&working_files, context);
}
},
};
let has_uppercase = fuzzy_parts
.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 {
prefilter: true,
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;
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
.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(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,
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 => "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);
let current_file_penalty =
calculate_current_file_penalty(file, total_frecency_score, context);
let total = total_frecency_score.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,
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 std::path::PathBuf;
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
let file_name = path.split('/').last().unwrap_or(path).to_string();
let file = 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,
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 context = ScoringContext {
raw_query: "test",
parsed_query: None,
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 = vec![
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 context = ScoringContext {
raw_query: "test",
parsed_query: None,
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 = vec![
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 context = ScoringContext {
raw_query: "test",
parsed_query: None,
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 {
prefilter: true,
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 {
prefilter: true,
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"
);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Thread-local sort buffer management for glidesort optimization
//!
//! This module provides thread-local buffers for glidesort's with_buffer API,
//! eliminating allocations in the hot path of fuzzy search operations.
use std::cell::RefCell;
use std::mem::MaybeUninit;
// glidesort requires a buffer to allocate, we use one reused buffer as it can grow pretty big
// for a large projects, this effectively saves 12kb of allocation on every search in linux repo
thread_local! {
static SORT_BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(1024));
}
pub fn sort_with_buffer<T, F>(slice: &mut [T], compare: F)
where
F: FnMut(&T, &T) -> std::cmp::Ordering,
{
SORT_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
// Calculate required buffer size in u8 units
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
let size_of_usize = std::mem::size_of::<u8>();
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
// Ensure buffer has enough capacity
if buffer.len() < required_usizes {
buffer.resize(required_usizes, 0);
}
// Cast u8 buffer to MaybeUninit<T> slice
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
// the buffer is large enough
let typed_buffer = unsafe {
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
};
glidesort::sort_with_buffer_by(slice, typed_buffer, compare);
});
}
pub fn sort_by_key_with_buffer<T, K, F>(slice: &mut [T], key_fn: F)
where
K: Ord,
F: FnMut(&T) -> K,
{
SORT_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
// Calculate required buffer size in u8 units
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
let size_of_usize = std::mem::size_of::<u8>();
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
// Ensure buffer has enough capacity
if buffer.len() < required_usizes {
buffer.resize(required_usizes, 0);
}
// Cast u8 buffer to MaybeUninit<T> slice
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
// the buffer is large enough
let typed_buffer = unsafe {
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
};
glidesort::sort_with_buffer_by_key(slice, typed_buffer, key_fn);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sort_with_buffer() {
let mut data = vec![5, 2, 8, 1, 9];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 2, 5, 8, 9]);
}
#[test]
fn test_sort_by_key_with_buffer() {
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
sort_by_key_with_buffer(&mut data, |item| item.0);
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
}
#[test]
fn test_reverse_sort() {
let mut data = vec![1, 2, 3, 4, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![5, 4, 3, 2, 1]);
}
#[test]
fn test_multiple_sorts_reuse_buffer() {
// This test verifies that multiple sorts on the same thread reuse the buffer
let mut data1 = vec![5, 2, 8, 1, 9];
sort_with_buffer(&mut data1, |a, b| a.cmp(b));
let mut data2 = vec![15, 12, 18, 11, 19];
sort_with_buffer(&mut data2, |a, b| a.cmp(b));
assert_eq!(data1, vec![1, 2, 5, 8, 9]);
assert_eq!(data2, vec![11, 12, 15, 18, 19]);
}
#[test]
fn test_empty_slice() {
let mut data: Vec<i32> = vec![];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, Vec::<i32>::new());
}
#[test]
fn test_single_element() {
let mut data = vec![42];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![42]);
}
#[test]
fn test_already_sorted() {
let mut data = vec![1, 2, 3, 4, 5];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 2, 3, 4, 5]);
}
#[test]
fn test_with_duplicates() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 1, 2, 3, 4, 5, 5, 6, 9]);
}
#[test]
fn test_descending_order() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![9, 6, 5, 5, 4, 3, 2, 1, 1]);
}
#[test]
fn test_simple_descending() {
// 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));
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");
}
}
+214
View File
@@ -0,0 +1,214 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use memmap2::Mmap;
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
/// A single indexed file with metadata, frecency scores, and lazy mmap.
///
/// The `mmap` field holds the memory-mapped file contents, initialized lazily
/// on the first grep access and cached for subsequent searches. The mmap is
/// backed by the kernel page cache and automatically reflects file modifications
/// — no manual invalidation is needed.
///
/// 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 memory-mapped file contents for grep.
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
/// Automatically reflects file changes via the kernel page cache.
mmap: OnceLock<Mmap>,
}
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 mmap — the clone lazily re-creates it on demand
mmap: 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,
mmap: OnceLock::new(),
}
}
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
///
/// Call this when the background watcher detects that the file has been modified.
/// While the kernel page cache reflects content changes automatically, a file
/// that is truncated (made smaller) while mapped can cause SIGBUS if the search
/// accesses pages beyond the new file size. Invalidating the mmap ensures a
/// fresh mapping with the correct size is created on the next access.
pub fn invalidate_mmap(&mut self) {
self.mmap = OnceLock::new();
}
/// Get the cached mmap or lazily create it. Returns `None` if the file
/// is too large, empty, or can't be opened/mapped.
///
/// After the first call, this is lock-free (just an atomic load + pointer deref).
/// The mmap is backed by the kernel page cache and automatically reflects
/// file modifications — no manual invalidation is needed.
#[inline]
pub fn get_mmap(&self) -> Option<&Mmap> {
if let Some(mmap) = self.mmap.get() {
return Some(mmap);
}
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
return None;
}
let file = std::fs::File::open(&self.path).ok()?;
// SAFETY: The mmap is backed by the kernel page cache and automatically
// reflects file modifications. The only risk is SIGBUS if the file is
// truncated while mapped
let mmap = unsafe { Mmap::map(&file) }.ok()?;
// If another thread raced us, OnceLock discards our mmap and returns theirs.
// This is fine — the duplicate mmap is just dropped.
Some(self.mmap.get_or_init(|| mmap))
}
}
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)]
pub struct Score {
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: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct PaginationArgs {
pub offset: usize,
pub limit: usize,
}
/// Context for scoring files during search.
///
/// The `parsed_query` field contains the pre-parsed query with constraints,
/// fuzzy parts, and location information. Parsing is done once at the API
/// boundary and passed through.
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
/// The original raw query string (for compatibility and debugging)
pub raw_query: &'a str,
/// Pre-parsed query containing constraints, fuzzy parts, and location
pub parsed_query: Option<FFFQuery<'a>>,
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<&'a QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
}
impl<'a> ScoringContext<'a> {
/// Get the effective fuzzy query string for matching.
/// Returns the first fuzzy part, or the raw query if no parsing was done.
pub fn effective_query(&self) -> &'a str {
match &self.parsed_query {
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.raw_query.trim(),
},
None => self.raw_query.trim(),
}
}
}
#[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
+84
View File
@@ -0,0 +1,84 @@
[package]
name = "fff-nvim"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[[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-core = { path = "../fff-core" }
fff-query-parser = { path = "../fff-query-parser" }
# 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"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true }
[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
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
# On Windows, git2 uses the native SChannel TLS backend
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
@@ -0,0 +1,666 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
use fff_nvim::types::PaginationArgs;
use std::path::PathBuf;
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 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 = fff_core::path_utils::canonicalize(&big_repo_path)
.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 fff_core::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(|| {
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),
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 = 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),
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 = 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),
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 = 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),
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 = 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),
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(query),
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("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("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("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("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 = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("pagination");
group.sample_size(100);
let query = "mod";
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(query),
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(query),
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(query),
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);
@@ -0,0 +1,224 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::query_tracker::QueryTracker;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn generate_random_string(len: usize) -> String {
thread_rng()
.sample_iter(&Alphanumeric)
.take(len)
.map(char::from)
.collect()
}
// 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()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Generate some common queries that will be reused
let common_queries = vec![
"main",
"test",
"config",
"utils",
"lib",
"mod",
"index",
"init",
"server",
"client",
"api",
"service",
"controller",
"model",
"view",
"component",
"handler",
"middleware",
"router",
"database",
"auth",
];
// Generate some common project paths
let project_paths = vec![
"/home/user/project1",
"/home/user/project2",
"/home/user/web-app",
"/home/user/cli-tool",
"/home/user/library",
];
for _ in 0..num_entries {
let query = if rng.gen_bool(0.7) {
// 70% chance to use common query
common_queries.choose(&mut rng).unwrap().to_string()
} else {
// 30% chance to use random query
generate_random_string(rng.gen_range(3..15))
};
let project_path = project_paths.choose(&mut rng).unwrap();
let file_name = format!(
"{}.{}",
generate_random_string(rng.gen_range(5..20)),
if rng.gen_bool(0.5) { "rs" } else { "js" }
);
let file_path = PathBuf::from(format!("{}/src/{}", project_path, file_name));
let entry = TestQueryEntry {
query: query.into(),
project_path: PathBuf::from(project_path),
file_path,
open_count: rng.gen_range(1..10),
last_opened: now - rng.gen_range(0..30 * 24 * 3600), // Random time within last 30 days
};
entries.push(entry);
}
entries
}
fn setup_tracker_with_data(entries: &[TestQueryEntry]) -> (QueryTracker, PathBuf) {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_dir =
std::env::temp_dir().join(format!("fff_bench_{}_{}", timestamp, rand::random::<u32>()));
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
// Insert all test data
for entry in entries {
for _ in 0..entry.open_count {
tracker
.track_query_completion(&entry.query, &entry.project_path, &entry.file_path)
.unwrap();
}
}
(tracker, temp_dir)
}
fn cleanup_tracker_dir(dir: PathBuf) {
if dir.exists() {
let _ = std::fs::remove_dir_all(dir);
}
}
fn bench_track_query_completion(c: &mut Criterion) {
let mut group = c.benchmark_group("track_query_completion");
for size in &[100, 1000, 10000] {
let entries = generate_test_data(*size);
let (mut tracker, temp_dir) = setup_tracker_with_data(&entries[..*size / 2]); // Pre-populate with half
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| {
let mut rng = thread_rng();
b.iter(|| {
let entry = entries.choose(&mut rng).unwrap();
black_box(
tracker
.track_query_completion(
black_box(&entry.query),
black_box(&entry.project_path),
black_box(&entry.file_path),
)
.unwrap(),
);
});
});
drop(tracker);
cleanup_tracker_dir(temp_dir);
}
group.finish();
}
fn bench_realistic_workload(c: &mut Criterion) {
let mut group = c.benchmark_group("realistic_workload");
for size in &[1000, 10000] {
let entries = generate_test_data(*size);
let (mut tracker, temp_dir) = setup_tracker_with_data(&entries);
group.bench_with_input(BenchmarkId::new("mixed_operations", size), size, |b, _| {
let mut rng = thread_rng();
b.iter(|| {
let entry = entries.choose(&mut rng).unwrap();
// Simulate realistic usage: 70% lookups, 25% tracking, 5% history
match rng.gen_range(0..100) {
0..70 => {
// Query entry lookup (most common operation)
let entry_result = black_box(
tracker
.get_last_query_entry(
black_box(&entry.query),
black_box(&entry.project_path),
3,
)
.unwrap(),
);
black_box(entry_result);
}
70..95 => {
// Track completion (when user opens file)
black_box(
tracker
.track_query_completion(
black_box(&entry.query),
black_box(&entry.project_path),
black_box(&entry.file_path),
)
.unwrap(),
);
}
95..100 => {
// Get historical query (least common)
let history = black_box(
tracker
.get_historical_query(black_box(&entry.project_path), black_box(5))
.unwrap(),
);
black_box(history);
}
_ => unreachable!(),
}
});
});
drop(tracker);
cleanup_tracker_dir(temp_dir);
}
group.finish();
}
criterion_group!(
benches,
bench_track_query_completion,
// Commented out - methods removed/changed in refactor:
// bench_get_query_boost,
// bench_cleanup_old_entries,
bench_realistic_workload
);
criterion_main!(benches);
@@ -0,0 +1,139 @@
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
use fff_core::file_picker::FilePicker;
use fff_core::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::time::Instant;
fn main() {
let big_repo_path = std::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 =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
eprintln!("Loading files from: {:?}", canonical_path);
// Directly scan without background thread
let start = Instant::now();
let files = {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(&canonical_path)
.hidden(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, &canonical_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();
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
entry.metadata().ok().map_or(0, |m| m.len()),
0,
None,
false,
));
});
files
};
eprintln!(
"✓ Loaded {} files in {:.2}s\n",
files.len(),
start.elapsed().as_secs_f64()
);
// Test queries
let test_queries = vec![
("short_common", "mod", 500),
("medium_specific", "controller", 200),
("long_rare", "user_authentication", 100),
("typo_resistant", "contrlr", 200),
("path_like", "src/lib", 150),
("single_char", "a", 300),
("two_char", "st", 300),
("partial_word", "test", 200),
("deep_path", "drivers/net", 100),
("extension", ".rs", 200),
];
eprintln!("Running search profiler...");
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
eprintln!("----------------------|------------|------------|-----------|--------");
let global_start = Instant::now();
let mut total_iterations = 0;
for (name, query, iterations) in test_queries {
let start = Instant::now();
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,
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,
},
},
);
match_count += results.total_matched;
}
let elapsed = start.elapsed();
let avg_time = elapsed / iterations as u32;
eprintln!(
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
name,
iterations,
elapsed.as_secs_f64(),
avg_time.as_micros(),
match_count / iterations
);
total_iterations += iterations;
}
let total_time = global_start.elapsed();
eprintln!("\n=== Summary ===");
eprintln!("Total searches: {}", total_iterations);
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
eprintln!(
"Average per search: {}µs",
(total_time.as_micros() as usize) / total_iterations
);
eprintln!(
"Searches per sec: {:.0}",
total_iterations as f64 / total_time.as_secs_f64()
);
eprintln!(
"\nYou can now run: perf record -g --call-graph dwarf -F 999 ./target/release/search_only"
);
}
+199
View File
@@ -0,0 +1,199 @@
use fff_core::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_core::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
};
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = grep_search(files, query, 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_core::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 ===");
}
+402
View File
@@ -0,0 +1,402 @@
use fff_core::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_core::grep::{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 {
files,
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,
},
}
}
/// 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, query, parsed, &self.options);
let elapsed = start.elapsed();
(
elapsed,
result.total_match_count,
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_core::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/5] 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/5] 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/5] Warm cache benchmarks (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);
}
eprintln!("\n[4/5] Incremental typing simulation");
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!("[5/5] 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,
};
let start = Instant::now();
let result = grep_search(&files, pagination_query, 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");
}
+471
View File
@@ -0,0 +1,471 @@
use fff_core::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_core::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,
};
let start = Instant::now();
let result = grep_search(files, query, parsed, &options);
let elapsed = start.elapsed();
(result.total_match_count, 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,
};
let start = Instant::now();
let result = grep_search(files, query, parsed, &options);
let elapsed = start.elapsed();
// Use matches.len() — the actual truncated page the UI would display,
// not total_match_count which includes overshoot from parallel batches.
(result.matches.len(), elapsed)
}
#[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_core::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,4 +1,5 @@
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::env;
use std::thread;
use std::time::Duration;
@@ -83,13 +84,24 @@ fn test_search_memory_pattern(
let (result_count, _total_matched) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let parser = QueryParser::default();
let parsed = parser.parse(&query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&query,
50 + (i % 50), // Vary result count
1 + (i % 4), // Vary thread count
None,
false, // prompt_position not relevant for test
parsed,
FuzzySearchOptions {
max_threads: 1 + (i % 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: 50 + (i % 50),
},
},
);
(search_result.items.len(), search_result.total_matched)
} else {
+177
View File
@@ -0,0 +1,177 @@
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::time::{Duration, Instant};
/// Wait for background scan to complete
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
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();
if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}",
start.elapsed().as_secs_f64(),
is_scanning,
file_count
);
}
if !is_scanning && file_count > 0 {
return Ok(file_count);
}
} else if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None",
start.elapsed().as_secs_f64()
);
}
if start.elapsed() > timeout {
return Err(format!("Scan timed out after {} seconds", timeout_secs));
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Initialize FilePicker and insert into global state
fn init_file_picker(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Get files snapshot from global state
fn get_files() -> Result<Vec<FileItem>, String> {
let picker_guard = FILE_PICKER
.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())
}
}
fn main() {
let big_repo_path = std::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 =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
init_file_picker(&canonical_path.to_string_lossy()).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");
eprintln!("✓ Indexed {} files\n", file_count);
let files = get_files().expect("Failed to get files");
// Test queries representing different search patterns
let test_queries = vec![
("short_common", "mod", 5000),
("medium_specific", "controller", 2000),
("long_rare", "user_authentication", 1000),
("typo_resistant", "contrlr", 2000),
("path_like", "src/lib", 1500),
("single_char", "a", 3000),
("two_char", "st", 3000),
("partial_word", "test", 2000),
("deep_path", "drivers/net", 1000),
("extension", ".rs", 2000),
];
eprintln!("Running search profiler...");
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
eprintln!("----------------------|------------|------------|-----------|--------");
let global_start = Instant::now();
let mut total_iterations = 0;
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,
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,
},
},
);
match_count += results.total_matched;
}
let elapsed = start.elapsed();
let avg_time = elapsed / iterations as u32;
eprintln!(
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
name,
iterations,
elapsed.as_secs_f64(),
avg_time.as_micros(),
match_count / iterations
);
total_iterations += iterations;
}
let total_time = global_start.elapsed();
eprintln!("\n=== Summary ===");
eprintln!("Total searches: {}", total_iterations);
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
eprintln!(
"Average per search: {}µs",
(total_time.as_micros() as usize) / total_iterations
);
eprintln!(
"Searches per sec: {:.0}",
total_iterations as f64 / total_time.as_secs_f64()
);
// Keep the program alive briefly so perf can capture everything
std::thread::sleep(Duration::from_millis(100));
}
@@ -1,4 +1,5 @@
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use fff_core::file_picker::FilePicker;
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::env;
use std::io::{self, Write};
use std::thread;
@@ -196,16 +197,27 @@ 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 parsed = parser.parse(query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
query,
max_results,
max_threads,
None,
false, // prompt_position not relevant for test
parsed,
FuzzySearchOptions {
max_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: max_results,
},
},
);
let duration = search_start.elapsed();
(search_result.items.len(), duration)
@@ -2,7 +2,9 @@
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
use fff_nvim::{FILE_PICKER, FRECENCY, file_picker::FilePicker, git::format_git_status};
use fff_core::file_picker::FilePicker;
use fff_core::git::format_git_status;
use fff_core::{FILE_PICKER, FRECENCY, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::env;
use std::io::{self, Write};
use std::sync::Arc;
@@ -15,7 +17,7 @@ fn cleanup_global_state() {
{
let mut file_picker = FILE_PICKER.write().unwrap();
if let Some(mut picker) = file_picker.take() {
let _ = picker.stop_background_monitor();
picker.stop_background_monitor();
drop(picker);
println!("🧹 FilePicker cleaned up");
}
@@ -156,7 +158,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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 search_results = FilePicker::fuzzy_search(files, "rs", 5, 2, None, false);
let parser = QueryParser::default();
let parsed = parser.parse("rs");
let search_results = FilePicker::fuzzy_search(
files,
"rs",
parsed,
FuzzySearchOptions {
max_threads: 2,
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: 5,
},
},
);
println!(
"🔍 [{}] Search test 'rs': {} matches",
+37
View File
@@ -0,0 +1,37 @@
//! Error handling for fff-nvim
//!
//! This module provides utilities for converting fff_core errors to mlua errors.
use fff_core::Error as CoreError;
/// Convert a fff_core::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_core::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)
}
}
+787
View File
@@ -0,0 +1,787 @@
use crate::path_shortening::shorten_path_with_cache;
use error::{IntoCoreError, IntoLuaResult};
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::query_tracker::QueryTracker;
use fff_core::{DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
use mimalloc::MiMalloc;
use mlua::prelude::*;
use path_shortening::PathShortenStrategy;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod error;
mod log;
mod lua_types;
mod path_shortening;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
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);
let mut query_tracker = QUERY_TRACKER
.write()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
if query_tracker.is_some() {
*query_tracker = None;
}
let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?;
*query_tracker = Some(tracker);
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 mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)
.into_lua_result()?;
if file_picker.is_some() {
return Ok(false);
}
let picker = FilePicker::new(base_path).into_lua_result()?;
*file_picker = Some(picker);
Ok(true)
}
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
let mut file_picker = FILE_PICKER
.write()
.with_lock_error(Error::AcquireItemLock)?;
// drop should clean it anyway but just to be extra sure
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
}
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
*file_picker = Some(new_picker);
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_core::path_utils::canonicalize(&path).map_err(|e| {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
// 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().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 last_same_query_entry = {
let query_tracker = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
if query_tracker.as_ref().is_none() {
tracing::warn!("Query tracker not initialized");
}
query_tracker
.as_ref()
.map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count))
.transpose()
.into_lua_result()?
.flatten()
};
tracing::debug!(
?last_same_query_entry,
?base_path,
?query,
?min_combo_count,
?page_index,
?page_size,
"Fuzzy search parameters"
);
// Parse the query once at the API boundary
let parser = QueryParser::default();
let parsed = parser.parse(&query);
let results = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
project_path: Some(picker.base_path()),
last_same_query_match: last_same_query_entry.as_ref(),
combo_boost_score_multiplier,
min_combo_count,
pagination: PaginationArgs {
offset: page_index.unwrap_or(0),
limit: page_size.unwrap_or(0),
},
},
);
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_core::grep::parse_grep_query(&query);
let mode = match grep_mode.as_deref() {
Some("regex") => fff_core::GrepMode::Regex,
Some("fuzzy") => fff_core::GrepMode::Fuzzy,
_ => fff_core::GrepMode::PlainText, // "plain" or nil or unknown
};
let options = fff_core::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),
};
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed, &options);
lua_types::GrepResultLua::from(result).into_lua(lua)
}
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_global().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_core::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)
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()
};
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> {
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 timeout_ms = timeout_ms.unwrap_or(500);
let timeout_duration = Duration::from_millis(timeout_ms);
let start_time = std::time::Instant::now();
let mut sleep_duration = Duration::from_millis(1);
while picker.is_scan_active() {
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)?)?;
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)
}
@@ -1,11 +1,84 @@
use crate::error::Error;
use std::io;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// Install panic hook that writes to both stderr and a fallback file
/// This is called separately from init_tracing to ensure panics are always logged
pub fn install_panic_hook() {
PANIC_HOOK_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
// Always log to tracing (if initialized)
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF.nvim"
);
// Always print to stderr
eprintln!("=== FFF.nvim PANIC ===");
eprintln!("Message: {}", message);
eprintln!("Location: {}", location);
eprintln!("======================");
// Try to write to fallback panic log file
if let Some(cache_dir) = dirs::cache_dir() {
let panic_log = cache_dir.join("fff_nvim_panic.log");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let panic_entry = format!(
"\n[{}] PANIC at {}\nMessage: {}\n",
timestamp, location, message
);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&panic_log)
.and_then(|mut f| {
use std::io::Write;
f.write_all(panic_entry.as_bytes())
});
eprintln!("Panic logged to: {}", panic_log.display());
}
default_panic(panic_info);
}));
});
}
/// Initialize tracing with single log file
///
/// # Arguments
@@ -13,8 +86,11 @@ static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::
/// * `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)?;
@@ -51,7 +127,8 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false),
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
@@ -68,36 +145,6 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
);
}
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
});
+183
View File
@@ -0,0 +1,183 @@
//! Lua type conversions for fff-core types
//!
//! This module provides IntoLua implementations for core types.
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepResult, Location, Score, SearchResult};
use mlua::prelude::*;
/// Wrapper for SearchResult that implements IntoLua
pub struct SearchResultLua<'a> {
inner: SearchResult<'a>,
}
impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
fn from(inner: SearchResult<'a>) -> Self {
Self { inner }
}
}
/// Wrapper for GrepResult that implements IntoLua
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.total_match_count)?;
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))]
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::debug!("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()
);
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "fff-query-parser"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/lib.rs"
[dependencies]
smallvec = { workspace = true }
zlob = { version = "1.2.8" }
[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(FilePickerConfig);
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);
+109
View File
@@ -0,0 +1,109 @@
use crate::constraints::Constraint;
use zlob::{ZlobFlags, has_wildcards};
/// 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
}
/// 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, ZlobFlags::RECOMMENDED)
}
/// 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 FilePickerConfig;
impl ParserConfig for FilePickerConfig {
// All defaults enabled
}
/// 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
}
/// 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, ZlobFlags::RECOMMENDED) {
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
if bytes.contains(&b'{') && bytes.contains(&b'}') {
return true;
}
// Everything else (?, [, bare * without /) → treat as literal text
false
}
}
@@ -0,0 +1,45 @@
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 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]>;
+251
View File
@@ -0,0 +1,251 @@
//! 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 None (no parsing needed)
//! let result = parser.parse("hello");
//! assert!(result.is_none());
//!
//! // Multi-token queries are parsed
//! let result = parser.parse("name *.rs").expect("Should parse");
//! 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").expect("Should parse");
//! assert!(matches!(result.constraints[0], Constraint::Glob("**/*.rs")));
//!
//! // Parse negation
//! let result = parser.parse("!*.rs foo").expect("Should parse");
//! 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 location;
mod parser;
pub use config::{FilePickerConfig, 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;
/// Type alias for constraint vector - stack-allocated for ≤8 constraints
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("");
// Empty query returns None (single-token behavior)
assert!(result.is_none());
}
#[test]
fn test_whitespace_only() {
let parser = QueryParser::default();
let result = parser.parse(" ");
// Whitespace-only returns None
assert!(result.is_none());
}
#[test]
fn test_single_token() {
let parser = QueryParser::default();
let result = parser.parse("hello");
// Single token returns None (no parsing needed)
assert!(result.is_none());
}
#[test]
fn test_simple_text() {
let parser = QueryParser::default();
let result = parser
.parse("hello world")
.expect("Should parse multi-token");
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")
.expect("Should parse single constraint");
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")
.expect("Should parse multi-token");
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").expect("Should parse multi-token");
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").expect("Should parse multi-token");
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")
.expect("Should parse multi-token");
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")
.expect("Should parse multi-token");
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")
.expect("Should parse");
// 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")
.expect("Should parse multi-token");
// 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")
.expect("Should parse");
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() {
+671
View File
@@ -0,0 +1,671 @@
use crate::ConstraintVec;
use crate::config::ParserConfig;
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
use crate::location::{Location, parse_location};
use zlob::{ZlobFlags, has_wildcards};
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::large_enum_variant)]
pub enum FuzzyQuery<'a> {
Parts(TextPartsBuffer<'a>),
Text(&'a str),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FFFQuery<'a> {
/// Parsed constraints (stack-allocated for ≤8 constraints)
pub constraints: ConstraintVec<'a>,
pub fuzzy_query: FuzzyQuery<'a>,
/// Parsed location (e.g., file:12:4 -> line 12, col 4)
pub location: Option<Location>,
}
/// Main query parser - zero-cost wrapper around configuration
#[derive(Debug)]
pub struct QueryParser<C: ParserConfig> {
config: C,
}
impl<C: ParserConfig> QueryParser<C> {
pub fn new(config: C) -> Self {
Self { config }
}
pub fn parse<'a>(&self, query: &'a str) -> Option<FFFQuery<'a>> {
let query: &'a str = query;
let config: &C = &self.config;
let mut constraints = ConstraintVec::new();
let query = query.trim();
let whitespace_count = query.chars().filter(|c| c.is_whitespace()).count();
// Single token - check if it's a constraint or plain text
if whitespace_count == 0 {
// Try to parse as constraint first
if let Some(constraint) = parse_token(query, config) {
constraints.push(constraint);
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Empty,
location: None,
});
}
// Try to extract location from single token (e.g., "file:12")
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
});
}
// Plain text single token - return None (caller handles as simple fuzzy match)
return None;
}
// Stack-allocated buffer for text parts (up to 16 parts)
let mut text_parts = TextPartsBuffer::new();
let tokens = query.split_whitespace();
for token in tokens {
match parse_token(token, config) {
Some(constraint) => {
constraints.push(constraint);
}
None => {
text_parts.push(token);
}
}
}
// Try to extract location from the last fuzzy token
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
let location = if !text_parts.is_empty() {
let last_idx = text_parts.len() - 1;
let (without_loc, loc) = parse_location(text_parts[last_idx]);
if loc.is_some() {
// Update the last part to be without the location suffix
text_parts[last_idx] = without_loc;
loc
} else {
None
}
} else {
None
};
let fuzzy_query = if text_parts.is_empty() {
FuzzyQuery::Empty
} else if text_parts.len() == 1 {
// If the only remaining text is empty after location extraction, treat as Empty
if text_parts[0].is_empty() {
FuzzyQuery::Empty
} else {
FuzzyQuery::Text(text_parts[0])
}
} else {
// Filter out empty parts that might result from location extraction
if text_parts.iter().all(|p| p.is_empty()) {
FuzzyQuery::Empty
} else {
FuzzyQuery::Parts(text_parts)
}
};
Some(FFFQuery {
constraints,
fuzzy_query,
location,
})
}
}
impl Default for QueryParser<crate::FilePickerConfig> {
fn default() -> Self {
Self::new(crate::FilePickerConfig)
}
}
#[inline]
fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
// Backslash escape: \token → treat as literal text, skip all constraint parsing.
// The leading \ is stripped by the caller when building the search text.
if token.starts_with('\\') && token.len() > 1 {
return None;
}
let first_byte = token.as_bytes().first()?;
match first_byte {
b'*' if config.enable_extension() => {
// Ignore incomplete patterns like "*" or "*."
if token == "*" || token == "*." {
return None;
}
// Try extension first (*.rs) - simple patterns without additional wildcards
if let Some(constraint) = parse_extension(token) {
// Only return Extension if the rest doesn't have wildcards
// e.g., *.rs is Extension, but *.test.* should be Glob
let ext_part = &token[2..];
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
return Some(constraint);
}
}
// Has wildcards -> use config-specific glob detection
if config.enable_glob() && config.is_glob_pattern(token) {
return Some(Constraint::Glob(token));
}
None
}
b'!' if config.enable_exclude() => parse_negation(token, config),
b'/' if config.enable_path_segments() => parse_path_segment(token),
_ if config.enable_path_segments() && token.ends_with('/') => {
// Handle trailing slash syntax: www/ -> PathSegment("www")
parse_path_segment_trailing(token)
}
_ => {
// Check for glob patterns using config-specific detection
if config.enable_glob() && config.is_glob_pattern(token) {
return Some(Constraint::Glob(token));
}
// Check for key:value patterns
if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
let (key, value_with_colon) = token.split_at(colon_idx);
let value = &value_with_colon[1..]; // Skip the colon
match key {
"type" if config.enable_type_filter() => {
return Some(Constraint::FileType(value));
}
"status" | "st" | "g" | "git" if config.enable_git_status() => {
return parse_git_status(value);
}
_ => {}
}
}
// Try custom parsers
config.parse_custom(token)
}
}
}
/// Find first occurrence of byte in slice (fast memchr-like implementation)
#[inline]
fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
haystack.iter().position(|&b| b == needle)
}
/// Parse extension pattern: *.rs -> Extension("rs")
#[inline]
fn parse_extension(token: &str) -> Option<Constraint<'_>> {
if token.len() > 2 && token.starts_with("*.") {
Some(Constraint::Extension(&token[2..]))
} else {
None
}
}
/// Parse negation pattern: !*.rs -> Not(Extension("rs")), !test -> Not(Text("test"))
/// This allows negating any constraint type
#[inline]
fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
if token.len() <= 1 {
return None;
}
let inner_token = &token[1..];
// Try to parse the inner token as any constraint
if let Some(inner_constraint) = parse_token_without_negation(inner_token, config) {
// Wrap it in a Not constraint
return Some(Constraint::Not(Box::new(inner_constraint)));
}
// If it's not a special constraint, treat it as negated text
// For backward compatibility with !test syntax
Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
}
/// Parse a token without checking for negation (to avoid infinite recursion)
#[inline]
fn parse_token_without_negation<'a, C: ParserConfig>(
token: &'a str,
config: &C,
) -> Option<Constraint<'a>> {
// Backslash escape applies here too
if token.starts_with('\\') && token.len() > 1 {
return None;
}
let first_byte = token.as_bytes().first()?;
match first_byte {
b'*' if config.enable_extension() => {
// Try extension first (*.rs) - simple patterns without additional wildcards
if let Some(constraint) = parse_extension(token) {
let ext_part = &token[2..];
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
return Some(constraint);
}
}
// Has wildcards -> use config-specific glob detection
if config.enable_glob() && config.is_glob_pattern(token) {
return Some(Constraint::Glob(token));
}
None
}
b'/' if config.enable_path_segments() => parse_path_segment(token),
_ if config.enable_path_segments() && token.ends_with('/') => {
// Handle trailing slash syntax: www/ -> PathSegment("www")
parse_path_segment_trailing(token)
}
_ => {
// Check for glob patterns using config-specific detection
if config.enable_glob() && config.is_glob_pattern(token) {
return Some(Constraint::Glob(token));
}
// Check for key:value patterns
if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
let (key, value_with_colon) = token.split_at(colon_idx);
let value = &value_with_colon[1..]; // Skip the colon
match key {
"type" if config.enable_type_filter() => {
return Some(Constraint::FileType(value));
}
"status" | "gi" | "g" | "st" if config.enable_git_status() => {
return parse_git_status(value);
}
_ => {}
}
}
config.parse_custom(token)
}
}
}
/// Parse path segment: /src/ -> PathSegment("src")
#[inline]
fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
if token.len() > 1 && token.starts_with('/') {
let segment = token.trim_start_matches('/').trim_end_matches('/');
if !segment.is_empty() {
Some(Constraint::PathSegment(segment))
} else {
None
}
} else {
None
}
}
/// Parse path segment with trailing slash: www/ -> PathSegment("www")
#[inline]
fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
if token.len() > 1 && token.ends_with('/') {
let segment = token.trim_end_matches('/');
if !segment.is_empty() && !segment.contains('/') {
Some(Constraint::PathSegment(segment))
} else {
None
}
} else {
None
}
}
/// Parse git status filter: modified|m|untracked|u|staged|s
#[inline]
fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
if value == "*" {
return None;
}
if "modified".starts_with(value) {
return Some(Constraint::GitStatus(GitStatusFilter::Modified));
}
if "untracked".starts_with(value) {
return Some(Constraint::GitStatus(GitStatusFilter::Untracked));
}
if "staged".starts_with(value) {
return Some(Constraint::GitStatus(GitStatusFilter::Staged));
}
if "clean".starts_with(value) {
return Some(Constraint::GitStatus(GitStatusFilter::Unmodified));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FilePickerConfig;
#[test]
fn test_parse_extension() {
assert_eq!(parse_extension("*.rs"), Some(Constraint::Extension("rs")));
assert_eq!(
parse_extension("*.toml"),
Some(Constraint::Extension("toml"))
);
assert_eq!(parse_extension("*"), None);
assert_eq!(parse_extension("*."), None);
}
#[test]
fn test_incomplete_patterns_ignored() {
let config = FilePickerConfig;
// Incomplete patterns should return None and be treated as noise
assert_eq!(parse_token("*", &config), None);
assert_eq!(parse_token("*.", &config), None);
}
#[test]
fn test_parse_path_segment() {
assert_eq!(
parse_path_segment("/src/"),
Some(Constraint::PathSegment("src"))
);
assert_eq!(
parse_path_segment("/lib"),
Some(Constraint::PathSegment("lib"))
);
assert_eq!(parse_path_segment("/"), None);
}
#[test]
fn test_parse_path_segment_trailing() {
assert_eq!(
parse_path_segment_trailing("www/"),
Some(Constraint::PathSegment("www"))
);
assert_eq!(
parse_path_segment_trailing("src/"),
Some(Constraint::PathSegment("src"))
);
// Should not match paths with multiple segments
assert_eq!(parse_path_segment_trailing("src/lib/"), None);
// Should not match without trailing slash
assert_eq!(parse_path_segment_trailing("www"), None);
}
#[test]
fn test_trailing_slash_in_query() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("www/ test")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::PathSegment("www")
));
assert!(matches!(result.fuzzy_query, FuzzyQuery::Text("test")));
}
#[test]
fn test_parse_git_status() {
assert_eq!(
parse_git_status("modified"),
Some(Constraint::GitStatus(GitStatusFilter::Modified))
);
assert_eq!(
parse_git_status("m"),
Some(Constraint::GitStatus(GitStatusFilter::Modified))
);
assert_eq!(
parse_git_status("untracked"),
Some(Constraint::GitStatus(GitStatusFilter::Untracked))
);
assert_eq!(parse_git_status("invalid"), None);
}
#[test]
fn test_memchr() {
assert_eq!(memchr(b':', b"type:rust"), Some(4));
assert_eq!(memchr(b':', b"nocolon"), None);
assert_eq!(memchr(b':', b":start"), Some(0));
}
#[test]
fn test_negation_text() {
let parser = QueryParser::new(FilePickerConfig);
// Need two tokens for parsing to return Some
let result = parser
.parse("!test foo")
.expect("Should parse multi-token query");
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_negation_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!*.rs foo")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
assert!(matches!(**inner, Constraint::Extension("rs")));
}
_ => panic!("Expected Not(Extension) constraint"),
}
}
#[test]
fn test_negation_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!/src/ foo")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
assert!(matches!(**inner, Constraint::PathSegment("src")));
}
_ => panic!("Expected Not(PathSegment) constraint"),
}
}
#[test]
fn test_negation_git_status() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!status:modified foo")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
assert!(matches!(
**inner,
Constraint::GitStatus(GitStatusFilter::Modified)
));
}
_ => panic!("Expected Not(GitStatus) constraint"),
}
}
#[test]
fn test_backslash_escape_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\*.rs foo")
.expect("Should parse multi-token query");
// \*.rs should NOT be parsed as an Extension constraint
assert_eq!(result.constraints.len(), 0);
// Both tokens should be text
match result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], "\\*.rs");
assert_eq!(parts[1], "foo");
}
_ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
}
}
#[test]
fn test_backslash_escape_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\/src/ foo")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 0);
match result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
assert_eq!(parts[0], "\\/src/");
assert_eq!(parts[1], "foo");
}
_ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
}
}
#[test]
fn test_backslash_escape_negation() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\!test foo")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_grep_question_mark_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
// Single token "foo?" should return None (treated as plain text by caller)
let result = parser.parse("foo?");
assert!(result.is_none(), "foo? should be plain text in grep mode");
}
#[test]
fn test_grep_bracket_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser.parse("arr[0] something");
let result = result.expect("Should parse multi-token query");
// arr[0] should NOT be a glob in grep mode
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_grep_path_glob_is_constraint() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern src/**/*.rs")
.expect("Should parse with path glob");
// src/**/*.rs contains / so it should be treated as a glob
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::Glob("src/**/*.rs")
));
}
#[test]
fn test_grep_brace_is_constraint() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern {src,lib}")
.expect("Should parse with brace expansion");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::Glob("{src,lib}")
));
}
#[test]
fn test_grep_bare_star_is_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
// "a*b" contains * but no / or {} — should be text in grep mode
let result = parser.parse("a*b something");
let result = result.expect("Should parse");
assert_eq!(
result.constraints.len(),
0,
"bare * without / should be text"
);
}
#[test]
fn test_grep_negated_text() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !test")
.expect("Should parse negated text in grep mode");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
assert!(
matches!(**inner, Constraint::Text("test")),
"Expected Not(Text(\"test\")), got Not({:?})",
inner
);
}
other => panic!("Expected Not constraint, got {:?}", other),
}
}
#[test]
fn test_grep_negated_path_segment() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !/src/")
.expect("Should parse negated path segment in grep mode");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
assert!(
matches!(**inner, Constraint::PathSegment("src")),
"Expected Not(PathSegment(\"src\")), got Not({:?})",
inner
);
}
other => panic!("Expected Not constraint, got {:?}", other),
}
}
#[test]
fn test_grep_negated_extension() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !*.rs")
.expect("Should parse negated extension in grep mode");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
assert!(
matches!(**inner, Constraint::Extension("rs")),
"Expected Not(Extension(\"rs\")), got Not({:?})",
inner
);
}
other => panic!("Expected Not constraint, got {:?}", other),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "grep-searcher"
version = "0.1.16"
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);
}
}
}
+205
View File
@@ -0,0 +1,205 @@
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>(
&mut 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()
}
}
+236 -22
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 October 17
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 17
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -9,8 +9,7 @@ FFF.nvimFinally a smart fuzzy file picker for neovim.
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
**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.
@@ -75,6 +74,20 @@ 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',
}
}
}
@@ -86,10 +99,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 +141,16 @@ 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,
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,
@@ -142,7 +160,6 @@ all available options:
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
wrap_lines = false,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
@@ -155,11 +172,19 @@ 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>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
@@ -171,19 +196,74 @@ all available options:
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
-- 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_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
-- 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, -- 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
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- Live grep search configuration
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
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
}
})
<
@@ -217,19 +297,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
@@ -246,6 +313,153 @@ 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' },
}
})
<
When only one mode is configured, the mode indicator is hidden completely and
the cycle keybind does nothing.
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 ~
+62
View File
@@ -0,0 +1,62 @@
-- Single file Neovim config for testing fff.nvim locally
-- Usage: nvim -u /Users/neogoose/dev/fff.nvim/init.lua
-- Set up lazy.nvim plugin manager
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
'git',
'clone',
'--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
'--branch=stable',
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require('lazy').setup({
{
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
-- {
-- 'nvim-mini/mini.icons',
-- version = false,
-- config = true,
-- },
},
config = function()
require('fff').setup({
-- Configure fff.nvim here
ui = {
width = 0.8,
height = 0.8,
},
file_picker = {
auto_reload_on_write = true,
frecency_boost = true,
},
})
end,
},
}, {
root = vim.fn.stdpath('data') .. '/fff-empty-test',
lockfile = vim.fn.stdpath('data') .. '/fff-empty-test.json',
})
vim.opt.number = true
vim.opt.relativenumber = true
vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'Find files' })
vim.keymap.set('n', 'fg', function() require('fff').find_in_git_root() end, { desc = 'Find files in git root' })
vim.keymap.set('n', 'fr', function() require('fff').scan_files() end, { desc = 'Rescan files' })
vim.keymap.set('n', 'fs', function() require('fff').refresh_git_status() end, { desc = 'Refresh git status' })
vim.notify('FFF.nvim local config loaded! Press ff', vim.log.levels.INFO)
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1753316655,
"narHash": "sha256-tzWa2kmTEN69OEMhxFy+J2oWSvZP5QhEgXp3TROOzl0=",
"lastModified": 1767744144,
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
"owner": "ipetkov",
"repo": "crane",
"rev": "f35a3372d070c9e9ccb63ba7ce347f0634ddf3d2",
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1754060105,
"narHash": "sha256-di5L6e5Iiv+oegS07j9h23FdqEpXn0ZQqMlDOEMw1EY=",
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7eabdc701d7dbb810fd91a97ec358caa4c1fc50",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1756866691,
"narHash": "sha256-YWJsM0HfdFLcaoP5OeyzjX6MjGnJ0Acm+bg1QN8MKjo=",
"lastModified": 1770865833,
"narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "fb6dab6f320291a8edd31c1d67f078c6f7384a02",
"rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85",
"type": "github"
},
"original": {
+5 -3
View File
@@ -41,18 +41,19 @@
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
@@ -80,7 +81,8 @@
pname = "fff.nvim";
version = "main";
src = pkgs.lib.cleanSource ./.;
patchPhase = copy-dynamic-library;
postPatch = copy-dynamic-library;
doCheck = false; # Skip require check since we have a Rust FFI component
};
};
+238
View File
@@ -0,0 +1,238 @@
local M = {}
local overlay_state = {
left_buf = nil,
left_win = nil,
right_buf = nil,
right_win = nil,
ns_id = nil,
-- Cache last position to avoid unnecessary updates
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 = '├────'
local RIGHT_OVERLAY_CONTENT = '─┤'
local LEFT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(LEFT_OVERLAY_CONTENT)
local LEFT_HEADER_PADDING = LEFT_OVERLAY_WIDTH - 2
local RIGHT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(RIGHT_OVERLAY_CONTENT)
local COMBO_TEXT_FORMAT = 'Last Match (×%d combo) '
local LAST_MATCH_TEXT_FORMAT = 'Last Match '
function M.init(ns_id) overlay_state.ns_id = ns_id end
local function detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not items or #items == 0 then return nil, 0 end
local first_score = file_picker.get_file_score(1)
local last_score = file_picker.get_file_score(#items)
if first_score.combo_match_boost > combo_boost_score_multiplier then
return 1, first_score.combo_match_boost / combo_boost_score_multiplier
elseif last_score.combo_match_boost > combo_boost_score_multiplier then
return #items, last_score.combo_match_boost / combo_boost_score_multiplier
end
return nil, 0
end
local function create_header_text(combo_count, win_width, disable_combo_display)
local combo_text = nil
if disable_combo_display then
combo_text = LAST_MATCH_TEXT_FORMAT
else
combo_text = string.format(COMBO_TEXT_FORMAT, combo_count)
end
local text_len = vim.fn.strdisplaywidth(combo_text)
local available_for_content = win_width - LEFT_HEADER_PADDING - RIGHT_OVERLAY_WIDTH
local remaining_dashes = math.max(0, available_for_content - text_len)
return string.rep(' ', LEFT_HEADER_PADDING) .. combo_text .. string.rep('', remaining_dashes), text_len
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(
buf,
ns_id,
config.hl.combo_header,
line_idx - 1,
LEFT_HEADER_PADDING,
LEFT_HEADER_PADDING + text_len
)
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
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
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_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)
end
local function position_overlay_window(state_key, buf, width, row, col)
local win_config = {
relative = 'editor',
width = width,
height = 1,
row = row,
col = col,
style = 'minimal',
border = 'none',
focusable = false,
zindex = 250,
}
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
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')
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
if
overlay_state.last_row == combo_header_row
and overlay_state.last_col == list_config.col
and overlay_state.last_border_hl == border_hl
and overlay_state.left_win
and vim.api.nvim_win_is_valid(overlay_state.left_win)
and overlay_state.right_win
and vim.api.nvim_win_is_valid(overlay_state.right_win)
then
return
end
overlay_state.last_row = combo_header_row
overlay_state.last_col = list_config.col
overlay_state.last_border_hl = border_hl
local left_buf = get_or_create_overlay_buf('left_buf')
local right_buf = get_or_create_overlay_buf('right_buf')
update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl)
update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl)
position_overlay_window('left_win', left_buf, LEFT_OVERLAY_WIDTH, combo_header_row, list_config.col)
position_overlay_window(
'right_win',
right_buf,
RIGHT_OVERLAY_WIDTH,
combo_header_row,
list_config.col + list_config.width
)
end
local function clear_overlays_internal()
if overlay_state.left_win and vim.api.nvim_win_is_valid(overlay_state.left_win) then
vim.api.nvim_win_close(overlay_state.left_win, true)
overlay_state.left_win = nil
end
if overlay_state.right_win and vim.api.nvim_win_is_valid(overlay_state.right_win) then
vim.api.nvim_win_close(overlay_state.right_win, true)
overlay_state.right_win = nil
end
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)
local combo_item_index, combo_count = detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not combo_item_index then return false, nil, 0, nil end
local header_line, text_len = create_header_text(combo_count, win_width, disable_combo_display)
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,
list_buf,
list_win,
ns_id,
border_hl,
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()
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
overlay_state.was_rendered = is_rendering_now
-- 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()
overlay_state.was_rendered = false
end
return M
+62 -2
View File
@@ -1,6 +1,6 @@
local M = {}
---@class fff.conf.State
--@class fff.conf.State
local state = {
---@type table | nil
config = nil,
@@ -113,6 +113,8 @@ local function init()
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
show_scrollbar = true, -- Show scrollbar for pagination
path_shorten_strategy = 'middle_number', -- or 'middle', 'end'
},
preview = {
enabled = true,
@@ -122,7 +124,6 @@ local function init()
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
wrap_lines = false,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
@@ -140,6 +141,12 @@ local function init()
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
cycle_previous_query = '<C-Up>',
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
focus_list = '<leader>l',
focus_preview = '<leader>p',
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
@@ -151,20 +158,72 @@ local function init()
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment',
directory_path = 'Comment', -- Highlight for directory path in file list
-- 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 in grep results
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off (plain mode)
grep_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
},
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
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 = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 200, -- Maximum matches per file
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)
@@ -187,6 +246,7 @@ end
function M.toggle_debug()
local old_debug_state = state.config.debug.show_scores
state.config.debug.show_scores = not state.config.debug.show_scores
state.config.debug.show_file_info = state.config.debug.show_scores
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
+16 -7
View File
@@ -16,7 +16,7 @@ local function setup_global_autocmds(config)
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
if config.frecency.enabled then
vim.api.nvim_create_autocmd({ 'BufReadPost' }, {
vim.api.nvim_create_autocmd({ 'BufEnter' }, {
group = group,
desc = 'Track file access for FFF frecency',
callback = function(args)
@@ -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)
@@ -47,9 +49,14 @@ local function setup_global_autocmds(config)
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)
@@ -82,9 +89,11 @@ M.ensure_initialized = function()
end
end
local db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_nvim')
local ok, result = pcall(fuzzy.init_db, db_path, true)
if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end
local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency')
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end
ok, result = pcall(fuzzy.init_file_picker, config.base_path)
if not ok then
+7 -7
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,7 +24,7 @@ end
local function binary_exists(plugin_dir)
local binary_path = get_binary_path(plugin_dir)
local stat = uv.fs_stat(binary_path)
local stat = vim.uv.fs_stat(binary_path)
return stat and stat.type == 'file'
end
@@ -32,9 +32,9 @@ 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
@@ -89,7 +89,7 @@ local function download_from_github(version, binary_path, opts, callback)
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
if not ok then
uv.fs_unlink(binary_path)
vim.uv.fs_unlink(binary_path)
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
return
end
+14 -153
View File
@@ -10,46 +10,6 @@ M.provider_name = nil
M.setup_attempted = false
M.setup_failed = false
local directory_configs = {
['nvim-web-devicons'] = {
default = { icon = '󰉋', hl = 'DevIconDefault' },
open = { icon = '󰝰', hl = 'DevIconDefault' },
closed = { icon = '󰉋', hl = 'DevIconDefault' },
git = { icon = '', hl = 'DevIconGitIgnore' },
node_modules = { icon = '', hl = 'DevIconNodeModules' },
hidden = { icon = '󰘓', hl = 'DevIconDefault' },
},
['mini.icons'] = {
default = { icon = '󰉋', color = '#7aa2f7' },
open = { icon = '󰝰', color = '#7aa2f7' },
closed = { icon = '󰉋', color = '#7aa2f7' },
git = { icon = '', color = '#e24329' },
node_modules = { icon = '', color = '#8cc84b' },
hidden = { icon = '󰘓', color = '#6d8086' },
},
}
-- Special directory names and their icons
local special_directories = {
['.git'] = 'git',
['node_modules'] = 'node_modules',
['.vscode'] = 'hidden',
['.idea'] = 'hidden',
['.cache'] = 'hidden',
['.config'] = 'hidden',
['__pycache__'] = 'hidden',
['.pytest_cache'] = 'hidden',
['target'] = 'hidden',
['dist'] = 'hidden',
['build'] = 'hidden',
['out'] = 'hidden',
['.next'] = 'hidden',
['.nuxt'] = 'hidden',
['coverage'] = 'hidden',
}
M.highlight_cache = {}
function M.setup()
if M.provider_name then return true end
if M.setup_failed then return false end
@@ -66,150 +26,51 @@ function M.setup()
end
M.setup_failed = true
vim.notify('FFF Icons: No icon provider found. Please install nvim-web-devicons or mini.icons', vim.log.levels.WARN)
return false
end
--- Get icon for a directory
--- @param dirname string The directory name
--- @return string, string Icon and color/highlight
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_directory_icon(dirname)
if not M.setup() then
return '󰉋', '#7aa2f7' -- Default folder icon if no provider
end
if not M.setup() then return nil, nil end
local dir_type = 'default'
local basename = vim.fn.fnamemodify(dirname, ':t')
if special_directories[basename] then
dir_type = special_directories[basename]
elseif basename:match('^%.') then
dir_type = 'hidden'
end
local config = directory_configs[M.provider_name]
if not config or not config[dir_type] then dir_type = 'default' end
local icon_data = config[dir_type]
if M.provider_name == 'nvim-web-devicons' then
-- For nvim-web-devicons, try to get the actual icon first
if M.provider.get_icon then
local provider_icon, provider_hl = M.provider.get_icon(basename, nil, { default = false })
if provider_icon and provider_icon ~= '' then
return provider_icon, M.resolve_color(provider_hl or icon_data.hl)
end
local icon, hl = M.provider.get_icon(basename, nil, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
end
-- Use our configured icon
return icon_data.icon, M.resolve_color(icon_data.hl)
elseif M.provider_name == 'mini.icons' then
-- For mini.icons, try to get directory-specific icon
if M.provider.get then
local provider_data = M.provider.get('directory', basename)
if provider_data and provider_data.glyph and provider_data.glyph ~= '' then
return provider_data.glyph, M.get_color_from_highlight(provider_data.hl)
end
local icon, hl, is_default = M.provider.get('directory', basename)
if icon and icon ~= '' and hl then return icon, hl end
end
-- Use our configured icon
return icon_data.icon, icon_data.color
end
-- Fallback (shouldn't reach here)
return '󰉋', '#7aa2f7'
return nil, nil
end
--- Get icon for a file
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string, string Icon and color
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_icon(filename, extension, is_directory)
if not M.setup() then
if is_directory then
return '󰉋', '#7aa2f7'
else
return '󰈙', '#6d8086'
end
end
if not M.setup() then return nil, nil end
if is_directory then return M.get_directory_icon(filename) end
local icon, color_or_hl
if M.provider_name == 'nvim-web-devicons' then
icon, color_or_hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' then return icon, M.resolve_color(color_or_hl) end
local icon, hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
elseif M.provider_name == 'mini.icons' then
local icon_data = M.provider.get('file', filename)
if icon_data and icon_data.glyph and icon_data.glyph ~= '' then
return icon_data.glyph, M.get_color_from_highlight(icon_data.hl)
end
local icon, hl, _ = M.provider.get('file', filename)
if icon and icon ~= '' and hl then return icon, hl end
end
return '󰈙', '#6d8086'
end
--- Get folder icon (kept for compatibility)
--- @return string, string Icon and color
function M.get_folder_icon() return M.get_directory_icon('folder') end
--- Resolve color from highlight group or hex
--- @param color_or_hl string|nil Color hex or highlight group name
--- @return string Hex color
function M.resolve_color(color_or_hl)
if not color_or_hl or color_or_hl == '' then return '#6d8086' end
-- If it's already a hex color, return as-is
if color_or_hl:match('^#%x%x%x%x%x%x$') then return color_or_hl end
-- Try to resolve as highlight group
return M.get_color_from_highlight(color_or_hl)
end
--- Get hex color from highlight group
--- @param hl_group string Highlight group name
--- @return string Hex color
function M.get_color_from_highlight(hl_group)
if not hl_group or hl_group == '' then return '#6d8086' end
local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = hl_group })
if ok and hl and hl.fg then return string.format('#%06x', hl.fg) end
return '#6d8086' -- Fallback color
end
--- Get icon with display formatting and highlight group creation
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string, string Icon and highlight group name
function M.get_icon_display(filename, extension, is_directory)
local icon, color = M.get_icon(filename, extension, is_directory)
local hl_group = M.create_icon_highlight(color)
return icon, hl_group
end
--- Create or get cached highlight group for icon color
--- @param color string Hex color
--- @return string Highlight group name
function M.create_icon_highlight(color)
if not color or color == '' then color = '#6d8086' end
if not color:match('^#%x%x%x%x%x%x$') then color = M.resolve_color(color) end
local hl_name = 'FFFIcon' .. color:gsub('#', ''):upper()
if M.highlight_cache[hl_name] then return hl_name end
local ok = pcall(vim.api.nvim_set_hl, 0, hl_name, { fg = color })
if not ok then
color = '#6d8086'
hl_name = 'FFFIcon6D8086'
vim.api.nvim_set_hl(0, hl_name, { fg = color })
end
M.highlight_cache[hl_name] = true
return hl_name
return nil, nil
end
--- Check if directories are supported by current provider
+37
View File
@@ -225,4 +225,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
+41 -20
View File
@@ -11,17 +11,7 @@ M.state = {
}
function M.setup()
local db_path = vim.fn.stdpath('cache') .. '/fff_nvim'
local ok, result = pcall(fuzzy.init_db, db_path, true)
if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end
local config = require('fff.conf').get()
ok, result = pcall(fuzzy.init_file_picker, config.base_path)
if not ok then
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
return false
end
M.state.initialized = true
M.state.base_path = config.base_path
@@ -42,29 +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 Maximum number of results (optional)
--- @param max_threads number Maximum number of threads (optional)
--- @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, max_results, max_threads, current_file, reverse_order)
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 Items per page
--- @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
max_threads = max_threads or config.max_threads
max_threads = max_threads or config.max_threads or 4
page_index = page_index or 0
page_size = page_size or 0
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_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
offset,
page_size
)
local ok, search_result =
pcall(fuzzy.fuzzy_search_files, query, max_results, max_threads, current_file, reverse_order)
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
+180 -220
View File
@@ -71,6 +71,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 +105,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 +123,7 @@ local function init_dynamic_loading_async(file_path, callback)
fd = fd,
file_path = file_path,
position = 0,
remainder = '',
}
callback(true)
@@ -103,9 +138,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 +154,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 +180,15 @@ local function load_next_chunk_async(chunk_size, callback)
load_forward_chunk_async(chunk_size, callback)
end
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
local ensure_content_loaded_async
local function read_file_streaming_async(file_path, bufnr, callback)
local 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 +211,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,7 +238,7 @@ 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
@@ -191,46 +247,39 @@ local function ensure_content_loaded_async(target_line)
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 — apply highlighting with whatever we have
M.apply_location_highlighting(M.state.bufnr)
end
end)
end
@@ -267,6 +316,7 @@ M.state = {
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
}
--- Setup preview configuration
@@ -291,166 +341,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
@@ -536,6 +426,48 @@ 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
@@ -573,12 +505,14 @@ function M.preview_file(file_path, bufnr)
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)
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 +520,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, bufnr, 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,6 +537,9 @@ 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)
@@ -611,13 +549,18 @@ function M.preview_file(file_path, bufnr)
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)
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)
@@ -693,15 +636,13 @@ 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
M.state.file_handle:close()
@@ -726,7 +667,7 @@ function M.preview(file_path, bufnr, location)
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
elseif is_binary then
return M.preview_binary_file(file_path, bufnr)
else
return M.preview_file(file_path, bufnr)
@@ -778,8 +719,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,7 +735,13 @@ 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)
@@ -848,6 +796,9 @@ function M.clear_buffer(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
@@ -883,7 +834,16 @@ function M.apply_location_highlighting(bufnr)
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
+249
View File
@@ -0,0 +1,249 @@
--- File Renderer
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
local M = {}
--- Render Context passed to renderer functions
--- @class RenderContext
--- @field config table User configuration from conf.get()
--- @field items table[] Array of file items being rendered
--- @field cursor number Current cursor position (1-based index into items)
--- @field win_height number Window height in lines
--- @field win_width number Window width in columns
--- @field max_path_width number Maximum width for file paths
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
--- @field prompt_position string Prompt position: 'top' or 'bottom'
--- @field has_combo boolean Whether combo boost is active
--- @field combo_header_line string Formatted combo header line (if has_combo)
--- @field combo_header_text_len number Length of combo header text (if has_combo)
--- @field combo_item_index number Index of item with combo (usually 1)
--- @field display_start number Start index for displayed items
--- @field display_end number End index for displayed items
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
--- @field selected_files table<string, boolean> Map of selected file paths
--- @field query string Current search query
--- @field renderer table|nil Custom renderer (if provided via opts)
--- File Item structure from Rust
--- @class FileItem
--- @field path string Absolute file path
--- @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 number|nil Git status enum (if file is in git repo)
--- Renderer Interface:
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
--- Render a file item line
--- @param item FileItem File item from Rust
--- @param ctx RenderContext Render context with all state
--- @param item_idx number Item index (1-based)
--- @return string[] Array of line strings (1 or 2 lines if combo)
function M.render_line(item, ctx, item_idx)
local icons = require('fff.file_picker.icons')
local lines = {}
-- Check if this should have combo header (first item with combo boost)
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
if has_combo then table.insert(lines, ctx.combo_header_line) end
-- Get icon
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
-- 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 RenderContext 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.active_file,
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_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
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_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
end
end
-- 4. Frecency indicator
if ctx.debug_enabled then
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
if start_pos then
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
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_add_highlight(
buf,
ns_id,
ctx.config.hl.directory_path or 'Comment',
line_idx - 1,
prefix_len,
prefix_len + #dir_path
)
end
-- 6. Current file
if is_current_file then
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
virt_text = { { ' (current)', virt_text_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(item.git_status)
if base_hl and base_hl ~= '' then
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
local 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.active_file
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.active_file,
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_add_highlight(
buf,
ns_id,
ctx.config.hl.matched or 'IncSearch',
line_idx - 1,
match_start - 1,
match_end
)
end
end
end
return M
+18
View File
@@ -33,4 +33,22 @@ M.cleanup_file_picker = rust_module.cleanup_file_picker
M.init_tracing = rust_module.init_tracing
M.wait_for_initial_scan = rust_module.wait_for_initial_scan
-- Query tracking functions
M.init_query_db = rust_module.init_query_db
M.destroy_query_db = rust_module.destroy_query_db
M.track_query_completion = rust_module.track_query_completion
M.get_historical_query = rust_module.get_historical_query
M.track_grep_query = rust_module.track_grep_query
M.get_historical_grep_query = rust_module.get_historical_grep_query
-- Git functions
M.get_git_root = rust_module.get_git_root
-- Grep functions
M.live_grep = rust_module.live_grep
-- Utility functions
M.health_check = rust_module.health_check
M.shorten_path = rust_module.shorten_path
return M
+107 -46
View File
@@ -1,19 +1,5 @@
local M = {}
M.highlights = {
untracked = 'FFFGitUntracked',
modified = 'FFFGitModified',
deleted = 'FFFGitDeleted',
renamed = 'FFFGitRenamed',
staged_new = 'FFFGitStaged',
staged_modified = 'FFFGitStaged',
staged_deleted = 'FFFGitStaged',
ignored = 'FFFGitIgnored',
clean = '',
clear = '',
unknown = 'FFFGitUntracked',
}
-- git signs like borders
M.border_chars = {
untracked = '', -- Dotted vertical line
@@ -29,39 +15,83 @@ M.border_chars = {
clear = '',
}
M.border_highlights = {
untracked = 'FFFGitSignUntracked',
modified = 'FFFGitSignModified',
deleted = 'FFFGitSignDeleted',
renamed = 'FFFGitSignRenamed',
staged_new = 'FFFGitSignStaged',
staged_modified = 'FFFGitSignStaged',
staged_deleted = 'FFFGitSignStaged',
ignored = 'FFFGitSignIgnored',
clean = '',
clear = '',
unknown = 'FFFGitSignUntracked',
}
-- Cache for config-based highlight mappings
local highlights_cache = nil
local border_highlights_cache = nil
local border_highlights_selected_cache = nil
M.border_highlights_selected = {
untracked = 'FFFGitSignUntrackedSelected',
modified = 'FFFGitSignModifiedSelected',
deleted = 'FFFGitSignDeletedSelected',
renamed = 'FFFGitSignRenamedSelected',
staged_new = 'FFFGitSignStagedSelected',
staged_modified = 'FFFGitSignStagedSelected',
staged_deleted = 'FFFGitSignStagedSelected',
ignored = 'FFFGitSignIgnoredSelected',
clean = '',
clear = '',
unknown = 'FFFGitSignUntrackedSelected',
}
--- Build and cache highlight mappings from config
local function ensure_cache()
if highlights_cache then return end
function M.get_highlight(git_status) return M.highlights[git_status] or '' end
local config = require('fff.conf').get()
function M.get_border_highlight(git_status) return M.border_highlights[git_status] or '' end
highlights_cache = {
untracked = config.hl.git_untracked,
modified = config.hl.git_modified,
deleted = config.hl.git_deleted,
renamed = config.hl.git_renamed,
staged_new = config.hl.git_staged,
staged_modified = config.hl.git_staged,
staged_deleted = config.hl.git_staged,
ignored = config.hl.git_ignored,
clean = '',
clear = '',
unknown = config.hl.git_untracked,
}
function M.get_border_highlight_selected(git_status) return M.border_highlights_selected[git_status] or '' end
border_highlights_cache = {
untracked = config.hl.git_sign_untracked,
modified = config.hl.git_sign_modified,
deleted = config.hl.git_sign_deleted,
renamed = config.hl.git_sign_renamed,
staged_new = config.hl.git_sign_staged,
staged_modified = config.hl.git_sign_staged,
staged_deleted = config.hl.git_sign_staged,
ignored = config.hl.git_sign_ignored,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked,
}
border_highlights_selected_cache = {
untracked = config.hl.git_sign_untracked_selected,
modified = config.hl.git_sign_modified_selected,
deleted = config.hl.git_sign_deleted_selected,
renamed = config.hl.git_sign_renamed_selected,
staged_new = config.hl.git_sign_staged_selected,
staged_modified = config.hl.git_sign_staged_selected,
staged_deleted = config.hl.git_sign_staged_selected,
ignored = config.hl.git_sign_ignored_selected,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked_selected,
}
end
--- Get highlight group for git status text
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_text_highlight(git_status)
ensure_cache()
return highlights_cache and highlights_cache[git_status] or ''
end
--- Get border highlight group for git status
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_border_highlight(git_status)
ensure_cache()
return border_highlights_cache and border_highlights_cache[git_status] or ''
end
--- Get selected border highlight group for git status
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_border_highlight_selected(git_status)
ensure_cache()
return border_highlights_selected_cache and border_highlights_selected_cache[git_status] or ''
end
function M.get_border_char(git_status) return M.border_chars[git_status] or '' end
@@ -110,12 +140,12 @@ function M.setup_highlights()
{ 'FFFGitSignIgnored', 'FFFGitSignIgnoredSelected', '#4B5563', 8 },
}
local visual_bg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'gui')
local visual_bg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'cterm')
for _, hl in ipairs(git_highlights) do
local _, selected_hl, gui_fg, cterm_fg = hl[1], hl[2], hl[3], hl[4]
local visual_bg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'gui')
local visual_bg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'cterm')
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
@@ -130,6 +160,37 @@ function M.setup_highlights()
)
)
end
-- Selection highlight - use Directory/Number colors (better than green 'Added')
vim.cmd('highlight default link FFFSelected Directory')
local dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'gui')
local dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'cterm')
if dir_fg_gui == '' or dir_fg_gui == '-1' then
-- Directory not defined, try Number
dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'gui')
dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'cterm')
end
-- Fallback to blue if neither Directory nor Number have colors
local is_dark_bg = vim.o.background == 'dark'
local gui_fg = dir_fg_gui ~= '' and dir_fg_gui or (is_dark_bg and '#60A5FA' or '#0369A1')
local cterm_fg = dir_fg_cterm ~= '' and dir_fg_cterm or (is_dark_bg and '12' or '4')
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
-- Create combined highlight: Directory/Number foreground + Visual background
vim.cmd(
string.format(
'highlight default FFFSelectedActive guifg=%s guibg=%s ctermfg=%s ctermbg=%s',
gui_fg,
gui_bg,
cterm_fg,
cterm_bg
)
)
end
return M
+254
View File
@@ -0,0 +1,254 @@
--- Grep Renderer
--- Custom renderer for live grep results with file grouping.
--- Consecutive matches from the same file are grouped under a file header line.
--- The header reuses the same rendering as the file picker list (file_renderer)
--- for visual consistency — same icon, filename, directory path, git highlights.
local M = {}
local file_renderer = require('fff.file_renderer')
--- Build the file group header line using the same layout as file_renderer.
--- Delegates to file_renderer.render_line (with combo disabled).
---@param item table Grep match item (used for file metadata)
---@param ctx table Render context
---@return string The header line string
local function build_group_header(item, ctx)
-- file_renderer.render_line checks (item_idx == 1 and ctx.has_combo) for combo header.
-- We pass item_idx=0 and disable has_combo to suppress combo logic entirely.
local saved_has_combo = ctx.has_combo
ctx.has_combo = false
local lines = file_renderer.render_line(item, ctx, 0)
ctx.has_combo = saved_has_combo
return lines[1]
end
--- Apply highlights for a file group header line using file_renderer.
--- Delegates to file_renderer.apply_highlights so all highlight groups
--- (icon, filename, git text color, directory path, git sign) match exactly.
---@param item table Grep match item
---@param ctx table Render context
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer (header line)
local function apply_group_header_highlights(item, ctx, buf, ns_id, row)
local line_content = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1] or ''
-- file_renderer.apply_highlights uses 1-based line_idx and checks (cursor == item_idx).
-- Pass item_idx=0 so the header is never treated as the cursor item.
local saved_cursor = ctx.cursor
ctx.cursor = -1
file_renderer.apply_highlights(item, ctx, 0, buf, ns_id, row + 1, line_content)
ctx.cursor = saved_cursor
end
--- Render a grep match line (grouped: no filename, just location + content).
--- Format: " :line:col matched line content"
---@param item table Grep match item
---@param ctx table Render context
---@return string The match line string
local function render_match_line(item, ctx)
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
local separator = ' '
local raw_content = item.line_content or ''
local leading_ws = #raw_content - #raw_content:match('^%s*(.*)')
local content = vim.trim(raw_content)
-- Indent + location + separator + content
local indent = ' '
-- Prefix is always ASCII so byte length == display width
local prefix_display_w = #indent + #location + #separator
local available = ctx.win_width - prefix_display_w - 2
local was_truncated = false
local content_display_w = vim.fn.strdisplaywidth(content)
if content_display_w > available and available > 3 then
-- UTF-8 aware truncation: binary search for the character count that
-- fits within the available display width (handles multi-byte and wide chars)
local nchars = vim.fn.strchars(content)
local lo, hi = 0, nchars
while lo < hi do
local mid = math.floor((lo + hi + 1) / 2)
if vim.fn.strdisplaywidth(vim.fn.strcharpart(content, 0, mid)) <= available - 1 then
lo = mid
else
hi = mid - 1
end
end
content = vim.fn.strcharpart(content, 0, lo) .. ''
was_truncated = true
end
local line = indent .. location .. separator .. content
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
-- Store transient data on item for highlight pass
item._leading_ws = leading_ws
item._was_truncated = was_truncated
item._match_indent = #indent
item._content_offset = prefix_display_w -- byte offset where content starts in the line
item._trimmed_content = content -- trimmed content string for treesitter parsing
return line .. string.rep(' ', padding)
end
--- Apply highlights for a grouped match line.
---@param item table Grep match item
---@param ctx table Render context
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer
---@param line_content string The rendered line text
local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line_content)
local config = ctx.config
local is_cursor = item_idx == ctx.cursor
local indent = item._match_indent or 1
-- 1. Cursor line highlight — use hl_group + hl_eol instead of line_hl_group
-- so that higher-priority inline extmarks (IncSearch match ranges at 200)
-- cleanly override both fg and bg on the cursor line.
if is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
end_col = 0,
end_row = row + 1,
hl_group = config.hl.cursor,
hl_eol = true,
priority = 100,
})
end
-- 2. Location (:line:col) dimmed — use extmark with priority so it layers with cursor
local location_str = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
local loc_start = indent
local loc_end = loc_start + #location_str
if loc_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, loc_start, {
end_col = loc_end,
hl_group = config.hl.grep_line_number or 'LineNr',
priority = 150,
})
end
-- 3. Separator dimmed
local sep_start = loc_end
local sep_end = sep_start + 2
if sep_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, sep_start, {
end_col = sep_end,
hl_group = 'Comment',
priority = 150,
})
end
-- 4. Treesitter syntax highlighting for the content portion.
-- Priority 120: above CursorLine (100) so syntax is visible on cursor line,
-- below IncSearch match ranges (200) so search matches take precedence.
local content_start = sep_end
if item._trimmed_content and item.name then
local ts_hl = require('fff.treesitter_hl')
-- Resolve language once per file group (cache on the render context)
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
local lang = ctx._ts_lang_cache[item.name]
if lang == nil then
lang = ts_hl.lang_from_filename(item.name) or false
ctx._ts_lang_cache[item.name] = lang
end
if lang then
local highlights = ts_hl.get_line_highlights(item._trimmed_content, lang)
for _, hl in ipairs(highlights) do
local hl_start = content_start + hl.col
local hl_end = content_start + hl.end_col
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
hl_group = hl.hl_group,
priority = 120,
})
end
end
end
end
-- 5. Match ranges highlighted with IncSearch
-- Use extmarks with priority > cursor line (100) so IncSearch renders
-- properly on the selected line instead of being overridden by CursorLine.
if item.match_ranges then
local leading_ws = item._leading_ws or 0
for _, range in ipairs(item.match_ranges) do
local raw_start = range[1] or 0
local raw_end = range[2] or 0
local adj_start = raw_start - leading_ws
local adj_end = raw_end - leading_ws
if adj_end > 0 then
adj_start = math.max(0, adj_start)
local hl_start = content_start + adj_start
local hl_end = content_start + adj_end
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
hl_group = config.hl.grep_match or 'IncSearch',
priority = 200,
})
end
end
end
end
-- 6. Selection marker (per-occurrence in grep mode)
if ctx.selected_items then
local key = string.format('%s:%d:%d', item.path, item.line_number or 0, item.col or 0)
if ctx.selected_items[key] then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
sign_text = '',
sign_hl_group = config.hl.selected or 'FFFSelected',
priority = 1001,
})
end
end
end
--- Render a single item's lines (called by list_renderer's generate_item_lines).
--- Returns 2 lines [header, match] for the first match of a file group,
--- or 1 line [match] for subsequent matches in the same file.
---@param item table Grep match item
---@param ctx table Render context
---@param item_idx number 1-based item index
---@return string[]
function M.render_line(item, ctx, item_idx)
-- Track file grouping across the render pass via ctx
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
local is_new_group = (item.path ~= ctx._grep_last_file)
ctx._grep_last_file = item.path
local match_line = render_match_line(item, ctx)
if is_new_group then
local header_line = build_group_header(item, ctx)
item._has_group_header = true
return { header_line, match_line }
else
item._has_group_header = false
return { match_line }
end
end
--- Apply highlights for rendered lines (called by list_renderer's apply_all_highlights).
--- line_idx is the 1-based index of the item's LAST line (the match line).
--- If the item has a group header, it's at line_idx - 1.
---@param item table Grep match item
---@param ctx table Render context
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param line_idx number 1-based line index of the match line
---@param line_content string The rendered match line text
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
local row = line_idx - 1 -- 0-based for nvim API
-- Apply match line highlights
apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line_content)
-- If this item has a group header, highlight it (the line above)
-- using file_renderer for identical appearance to the file picker list.
if item._has_group_header then apply_group_header_highlights(item, ctx, buf, ns_id, row - 1) end
end
return M
+54
View File
@@ -0,0 +1,54 @@
--- Grep search bridge — wraps the Rust `live_grep` FFI function
--- with file-based pagination state tracking.
---@class fff.grep
local M = {}
local fuzzy = require('fff.fuzzy')
---@class fff.grep.SearchResult
---@field items table[] Array of grep match items
---@field total_matched number Total matches found in this call
---@field total_files_searched number Files actually searched in this call
---@field total_files number Total indexed files
---@field filtered_file_count number Total searchable files after filtering
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
local last_result = nil
--- Perform a grep search.
---@param query string The search query (may contain file constraints like *.rs)
---@param file_offset? number Index into sorted file list to start from (default 0)
---@param page_size? number Max matches to collect (default 50)
---@param config? table Grep configuration overrides
---@param grep_mode? string Search mode: "plain" (default), "regex", or "fuzzy"
---@return fff.grep.SearchResult
function M.search(query, file_offset, page_size, config, grep_mode)
local conf = config or {}
last_result = fuzzy.live_grep(
query or '',
file_offset or 0,
page_size or 50,
conf.max_file_size,
conf.max_matches_per_file,
conf.smart_case,
grep_mode or 'plain',
conf.time_budget_ms
)
return last_result
end
--- Get metadata from the last search result.
---@return { total_matched: number, total_files_searched: number, total_files: number, next_file_offset: number }
function M.get_search_metadata()
if not last_result then
return { total_matched = 0, total_files_searched = 0, total_files = 0, next_file_offset = 0 }
end
return {
total_matched = last_result.total_matched or 0,
total_files_searched = last_result.total_files_searched or 0,
total_files = last_result.total_files or 0,
next_file_offset = last_result.next_file_offset or 0,
}
end
return M
+360
View File
@@ -0,0 +1,360 @@
local utils = require('fff.utils')
local M = {}
local function fetch_rust_checkhealth(rust_module, test_path)
if not rust_module.health_check then
return nil, 'health_check function not available in rust module (binary may be outdated)'
end
local ok, result = pcall(rust_module.health_check, test_path)
if not ok then return nil, 'Failed to call health_check: ' .. tostring(result) end
return result, nil
end
--- Check snacks.nvim image preview availability
--- @return table image_preview_info
local function check_image_preview()
local ok, image = pcall(require, 'fff.file_picker.image')
if not ok then
return {
available = false,
snacks_available = false,
snacks_image_available = false,
terminal_supported = false,
error = 'failed to load image module',
}
end
return image.get_preview_status()
end
--- Check icon provider availability
--- @return table icon_provider_info
local function check_icon_provider()
local ok, icons = pcall(require, 'fff.file_picker.icons')
if not ok then return {
available = false,
name = nil,
supports_directories = false,
} end
return icons.get_provider_info()
end
--- Run the health check and return structured results
--- @param opts? { test_path?: string } Options for health check
--- @return table health_result
function M.run(opts)
opts = opts or {}
local health = {
ok = true,
binary = {
available = false,
path = nil,
error = nil,
},
rust = {
version = nil,
git = {
available = false,
repository_found = false,
workdir = nil,
libgit2_version = nil,
error = nil,
},
file_picker = {
initialized = false,
base_path = nil,
is_scanning = false,
indexed_files = 0,
error = nil,
},
frecency = {
initialized = false,
db_path = nil,
disk_size = nil,
entries = nil,
error = nil,
},
query_tracker = {
initialized = false,
db_path = nil,
disk_size = nil,
query_file_entries = nil,
query_history_entries = nil,
error = nil,
},
},
image_preview = {
available = false,
snacks_available = false,
snacks_image_available = false,
terminal_supported = false,
error = nil,
},
icon_provider = {
available = false,
name = nil,
supports_directories = false,
},
messages = {},
}
-- Check binary availability
local download = require('fff.download')
health.binary.path = download.get_binary_path()
local binary_ok, rust_module = pcall(require, 'fff.rust')
if not binary_ok then
health.ok = false
health.binary.available = false
health.binary.error = tostring(rust_module)
table.insert(health.messages, {
level = 'error',
msg = 'Binary not available: ' .. tostring(rust_module),
})
return health
end
health.binary.available = true
table.insert(health.messages, {
level = 'ok',
msg = 'Binary loaded successfully from: ' .. health.binary.path,
})
local rust_health, rust_err = fetch_rust_checkhealth(rust_module, opts.test_path)
if rust_health then
health.rust.version = rust_health.version
table.insert(health.messages, {
level = 'ok',
msg = 'fff.nvim version: ' .. (rust_health.version or 'unknown'),
})
if rust_health.git then
health.rust.git.available = rust_health.git.available
health.rust.git.repository_found = rust_health.git.repository_found
health.rust.git.workdir = rust_health.git.workdir
health.rust.git.libgit2_version = rust_health.git.libgit2_version
health.rust.git.error = rust_health.git.error
if rust_health.git.available then
table.insert(health.messages, {
level = 'ok',
msg = 'libgit2 available (version: ' .. (rust_health.git.libgit2_version or 'unknown') .. ')',
})
if rust_health.git.repository_found then
table.insert(health.messages, {
level = 'ok',
msg = 'Git repository found: ' .. (rust_health.git.workdir or 'unknown'),
})
else
table.insert(health.messages, {
level = 'info',
msg = 'No git repository found in current directory'
.. (rust_health.git.error and (': ' .. rust_health.git.error) or ''),
})
end
else
table.insert(health.messages, {
level = 'warn',
msg = 'libgit2 not available',
})
end
end
if rust_health.file_picker then
health.rust.file_picker.initialized = rust_health.file_picker.initialized
health.rust.file_picker.base_path = rust_health.file_picker.base_path
health.rust.file_picker.is_scanning = rust_health.file_picker.is_scanning
health.rust.file_picker.indexed_files = rust_health.file_picker.indexed_files
health.rust.file_picker.error = rust_health.file_picker.error
if rust_health.file_picker.initialized then
local status = rust_health.file_picker.is_scanning and 'scanning' or 'ready'
table.insert(health.messages, {
level = 'ok',
msg = string.format(
'File picker initialized (%s, %d files indexed, base: %s)',
status,
rust_health.file_picker.indexed_files or 0,
rust_health.file_picker.base_path or 'unknown'
),
})
else
table.insert(health.messages, {
level = 'info',
msg = 'File picker not initialized (will initialize on first use)',
})
end
end
-- Frecency database status
if rust_health.frecency then
health.rust.frecency.initialized = rust_health.frecency.initialized
health.rust.frecency.error = rust_health.frecency.error
if rust_health.frecency.initialized then
local db_info = rust_health.frecency.db_healthcheck
if db_info then
health.rust.frecency.db_path = db_info.path
health.rust.frecency.disk_size = db_info.disk_size
health.rust.frecency.entries = db_info.absolute_frecency_entries
table.insert(health.messages, {
level = 'ok',
msg = string.format(
'Frecency database initialized (%d entries, %s, path: %s)',
db_info.absolute_frecency_entries or 0,
utils.format_file_size(db_info.disk_size or 0),
db_info.path or 'unknown'
),
})
elseif rust_health.frecency.db_healthcheck_error then
table.insert(health.messages, {
level = 'warn',
msg = 'Frecency database initialized but health check failed: '
.. rust_health.frecency.db_healthcheck_error,
})
else
table.insert(health.messages, {
level = 'ok',
msg = 'Frecency database initialized',
})
end
else
table.insert(health.messages, {
level = 'info',
msg = 'Frecency database not initialized (will initialize on first use)',
})
end
end
if rust_health.query_tracker then
health.rust.query_tracker.initialized = rust_health.query_tracker.initialized
health.rust.query_tracker.error = rust_health.query_tracker.error
if rust_health.query_tracker.initialized then
local db_info = rust_health.query_tracker.db_healthcheck
if db_info then
health.rust.query_tracker.db_path = db_info.path
health.rust.query_tracker.disk_size = db_info.disk_size
health.rust.query_tracker.query_file_entries = db_info.query_file_entries
health.rust.query_tracker.query_history_entries = db_info.query_history_entries
table.insert(health.messages, {
level = 'ok',
msg = string.format(
'Query tracker initialized (%d query-file mappings, %d history entries, %s, path: %s)',
db_info.query_file_entries or 0,
db_info.query_history_entries or 0,
utils.format_file_size(db_info.disk_size or 0),
db_info.path or 'unknown'
),
})
elseif rust_health.query_tracker.db_healthcheck_error then
table.insert(health.messages, {
level = 'warn',
msg = 'Query tracker initialized but health check failed: '
.. rust_health.query_tracker.db_healthcheck_error,
})
else
table.insert(health.messages, {
level = 'ok',
msg = 'Query tracker initialized',
})
end
else
table.insert(health.messages, {
level = 'info',
msg = 'Query tracker not initialized (will initialize on first use)',
})
end
end
else
health.ok = false
table.insert(health.messages, {
level = 'error',
msg = rust_err or 'Unknown error getting rust health data',
})
return health
end
local image_info = check_image_preview()
health.image_preview.snacks_available = image_info.snacks_available
health.image_preview.snacks_image_available = image_info.snacks_image_available
health.image_preview.terminal_supported = image_info.terminal_supported
health.image_preview.error = image_info.error
health.image_preview.available = image_info.available
if image_info.available then
table.insert(health.messages, {
level = 'ok',
msg = 'Image preview available via snacks.nvim',
})
elseif image_info.snacks_available and image_info.snacks_image_available then
table.insert(health.messages, {
level = 'info',
msg = 'Image preview not available: ' .. (image_info.error or 'terminal does not support images'),
})
elseif image_info.snacks_available then
table.insert(health.messages, {
level = 'info',
msg = 'Image preview not available: snacks.image module not found',
})
else
table.insert(health.messages, {
level = 'info',
msg = 'Image preview not available: snacks.nvim not installed',
})
end
local icon_info = check_icon_provider()
health.icon_provider.available = icon_info.available
health.icon_provider.name = icon_info.name
health.icon_provider.supports_directories = icon_info.supports_directories
if icon_info.available then
table.insert(health.messages, {
level = 'ok',
msg = 'Filetype icons available via ' .. icon_info.name,
})
else
table.insert(health.messages, {
level = 'info',
msg = 'Filetype icons not available (install nvim-web-devicons or mini.icons)',
})
end
return health
end
function M.check()
vim.health.start('fff.nvim')
local result = M.run()
for _, msg in ipairs(result.messages) do
if msg.level == 'ok' then
vim.health.ok(msg.msg)
elseif msg.level == 'warn' then
vim.health.warn(msg.msg)
elseif msg.level == 'error' then
vim.health.error(msg.msg)
elseif msg.level == 'info' then
vim.health.info(msg.msg)
end
end
if not result.binary.available then
vim.health.info('To install the binary, run:')
vim.health.info(' :lua require("fff.download").download_or_build_binary()')
vim.health.info('Or build from source with:')
vim.health.info(' cargo build --release')
end
end
return M
+278
View File
@@ -0,0 +1,278 @@
--- List Renderer
--- Handles all list rendering: line generation, virtual rows, bottom padding,
--- buffer writes, cursor positioning, and highlight application.
---
--- Virtual rows (combo headers, grep file group headers) are decorations that
--- belong to buffer rendering, NOT to the data model. The cursor and selection
--- always operate on the items array (1-based indices), never on buffer lines.
---
--- Pagination is unaffected: Rust returns N items per page. The renderer may
--- produce N + K buffer lines (where K = number of virtual header rows), but
--- the page_size contract with Rust stays item-based.
---
--- Selection always operates on item.path keys. Virtual rows have no identity
--- of their own — they derive from the item they belong to.
local M = {}
--- @class ListRenderContext
--- @field config table User configuration
--- @field items table[] Array of data items to render
--- @field cursor number Current cursor position (1-based index into items)
--- @field win_height number Window height in lines
--- @field win_width number Window width in columns
--- @field max_path_width number Actual text area width (excluding signcolumn)
--- @field debug_enabled boolean Whether debug mode shows scores
--- @field prompt_position string 'top' or 'bottom'
--- @field has_combo boolean Whether combo boost is active
--- @field combo_header_line string|nil Formatted combo header line
--- @field combo_header_text_len number|nil Length of combo header text
--- @field combo_item_index number|nil Index of item with combo (usually 1)
--- @field display_start number Start index for displayed items (1)
--- @field display_end number End index for displayed items (#items)
--- @field iter_start number Iteration start
--- @field iter_end number Iteration end
--- @field iter_step number Iteration step (1 or -1)
--- @field renderer table|nil Custom renderer with render_line/apply_highlights
--- @field query string Current search query
--- @field selected_files table<string, boolean> Selected file paths set
--- @field mode string|nil Current mode (nil or 'grep')
--- @field format_file_display function Helper for formatting file display
--- @class ItemLineMapping
--- @field first number First buffer line (1-based) this item occupies
--- @field last number Last buffer line (1-based) — the selectable content line
--- @field virtual_count number Number of virtual (header) lines before the content line
--- @class ListRenderResult
--- @field lines string[] All buffer lines (including virtual rows and padding)
--- @field item_to_lines table<number, ItemLineMapping> Maps item index -> line range
--- @field padding_offset number Number of empty lines prepended for bottom prompt
--- @field total_content_lines number Lines before padding was applied
--- Generate all display lines from items using the renderer.
--- Each item may produce 1 or more lines (virtual header + content).
--- When cross-mode suggestions are active, a suggestion banner is prepended
--- (for top prompt) or appended (for bottom prompt) so it always appears
--- above the suggestion items visually.
--- @param ctx ListRenderContext
--- @return string[] lines Array of line strings
--- @return table<number, ItemLineMapping> item_to_lines
local function generate_item_lines(ctx)
local lines = {}
local item_to_lines = {}
-- Cross-mode suggestion header: rendered above items visually.
-- For top prompt that means before items; for bottom prompt after items
-- (because bottom prompt iterates in reverse).
local suggestion_header_lines = {}
local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0
if has_suggestion_header then
table.insert(suggestion_header_lines, '')
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
table.insert(suggestion_header_lines, '')
end
-- For top prompt: suggestion header goes before items
if has_suggestion_header and ctx.prompt_position ~= 'bottom' then
for _, hline in ipairs(suggestion_header_lines) do
table.insert(lines, hline)
end
end
local renderer = ctx.renderer
if not renderer then renderer = require('fff.file_renderer') end
for i = ctx.iter_start, ctx.iter_end, ctx.iter_step do
local item = ctx.items[i]
local item_start_line = #lines + 1
-- Renderer returns 1+ lines: virtual headers first, content line last.
-- This contract is shared by file_renderer (combo header) and
-- grep_renderer (file group header).
local item_lines = renderer.render_line(item, ctx, i)
for _, line in ipairs(item_lines) do
table.insert(lines, line)
end
local item_end_line = #lines
local virtual_count = item_end_line - item_start_line -- 0 if single line, 1 if header + content
item_to_lines[i] = {
first = item_start_line,
last = item_end_line,
virtual_count = virtual_count,
}
end
-- For bottom prompt: suggestion header goes after items (appears above visually)
if has_suggestion_header and ctx.prompt_position == 'bottom' then
for _, hline in ipairs(suggestion_header_lines) do
table.insert(lines, hline)
end
end
return lines, item_to_lines
end
--- Apply bottom padding: prepend empty lines so content sits at the bottom.
--- Adjusts all line indices in item_to_lines accordingly.
--- @param lines string[] Lines array (mutated)
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
--- @param ctx ListRenderContext
--- @return number padding_offset Number of empty lines prepended
local function apply_bottom_padding(lines, item_to_lines, ctx)
if ctx.prompt_position ~= 'bottom' then return 0 end
local total_content_lines = #lines
local empty_lines_needed = math.max(0, ctx.win_height - total_content_lines)
if empty_lines_needed > 0 then
-- Prepend empty lines
for _ = empty_lines_needed, 1, -1 do
table.insert(lines, 1, string.rep(' ', ctx.win_width + 5))
end
-- Shift all line indices
for i = ctx.display_start, ctx.display_end do
if item_to_lines[i] then
item_to_lines[i].first = item_to_lines[i].first + empty_lines_needed
item_to_lines[i].last = item_to_lines[i].last + empty_lines_needed
end
end
end
return empty_lines_needed
end
--- Write lines to the buffer and position the cursor on the correct line.
--- The cursor always targets the content line (last) of the current item,
--- never a virtual header line.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx ListRenderContext
--- @param list_buf number Buffer handle
--- @param list_win number Window handle
--- @param ns_id number Namespace id
local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
-- Resolve cursor to a buffer line — always the content line (last), not virtual rows
local cursor_line = 0
if #ctx.items > 0 and ctx.cursor >= 1 and ctx.cursor <= #ctx.items then
local cursor_item = item_to_lines[ctx.cursor]
if cursor_item then cursor_line = cursor_item.last end
end
vim.api.nvim_buf_set_option(list_buf, 'modifiable', true)
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
vim.api.nvim_buf_set_option(list_buf, 'modifiable', false)
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then
vim.api.nvim_win_set_cursor(list_win, { cursor_line, 0 })
end
end
--- Apply highlights for all items using the renderer's apply_highlights.
--- For each item, we pass the content line (last) to the renderer.
--- Renderers that emit virtual rows (grep_renderer) handle their own
--- header highlights internally via the item._has_group_header flag.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx ListRenderContext
--- @param list_buf number
--- @param ns_id number
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
local renderer = ctx.renderer
if not renderer then renderer = require('fff.file_renderer') end
for i = ctx.display_start, ctx.display_end do
local item = ctx.items[i]
local item_lines = item_to_lines[i]
if not item_lines then goto continue end
-- The content line is always the last line in the mapping
local line_idx = item_lines.last
local line_content = lines[line_idx]
if not line_content then goto continue end
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
::continue::
end
end
--- Render the full item list into the buffer.
--- This is the main entry point — replaces the inline rendering in picker_ui.
---
--- @param ctx ListRenderContext Render context built by picker_ui
--- @param list_buf number List buffer handle
--- @param list_win number List window handle
--- @param ns_id number Highlight namespace
--- @return table<number, ItemLineMapping> item_to_lines for combo/scrollbar use
function M.render(ctx, list_buf, list_win, ns_id)
local lines, item_to_lines = generate_item_lines(ctx)
apply_bottom_padding(lines, item_to_lines, ctx)
update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
if #ctx.items > 0 then apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id) end
-- Highlight the suggestion header lines (if present)
if ctx.suggestion_source and #ctx.items > 0 then
local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg'
for i = 0, #lines - 1 do
local line = lines[i + 1]
if line and line:match('^%s+No results found') then
pcall(vim.api.nvim_buf_add_highlight, list_buf, ns_id, suggestion_hl, i, 0, -1)
end
end
end
return item_to_lines
end
--- Get the buffer line for an item's content (selectable) line.
--- Used by picker_ui for cursor positioning after navigation.
--- @param item_to_lines table<number, ItemLineMapping>
--- @param item_index number 1-based item index
--- @return number|nil line 1-based buffer line, or nil if item not mapped
function M.get_content_line(item_to_lines, item_index)
local mapping = item_to_lines[item_index]
if not mapping then return nil end
return mapping.last
end
--- Get the buffer line for an item's first line (may be a virtual header).
--- Used by combo_renderer for overlay positioning.
--- @param item_to_lines table<number, ItemLineMapping>
--- @param item_index number 1-based item index
--- @return number|nil line 1-based buffer line, or nil if item not mapped
function M.get_first_line(item_to_lines, item_index)
local mapping = item_to_lines[item_index]
if not mapping then return nil end
return mapping.first
end
--- Check if an item has virtual (header) rows.
--- @param item_to_lines table<number, ItemLineMapping>
--- @param item_index number 1-based item index
--- @return boolean
function M.has_virtual_rows(item_to_lines, item_index)
local mapping = item_to_lines[item_index]
if not mapping then return false end
return mapping.virtual_count > 0
end
--- Count total buffer lines an item occupies (content + virtual).
--- @param item_to_lines table<number, ItemLineMapping>
--- @param item_index number 1-based item index
--- @return number
function M.get_line_count(item_to_lines, item_index)
local mapping = item_to_lines[item_index]
if not mapping then return 0 end
return mapping.last - mapping.first + 1
end
return M
+97
View File
@@ -38,6 +38,11 @@ function M.highlight_location(bufnr, location, namespace)
local line_count = vim.api.nvim_buf_line_count(bufnr)
local extmarks = {}
-- Grep mode: highlight all occurrences of the search pattern across visible lines
if location.grep_query and location.grep_query ~= '' then
return M.highlight_grep_matches(bufnr, location, namespace)
end
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
@@ -129,6 +134,98 @@ function M.highlight_location(bufnr, location, namespace)
return #extmarks > 0 and extmarks or nil
end
--- Highlight all occurrences of a grep pattern in the preview buffer.
--- For plain text and regex modes: highlights every match on all loaded lines
--- using Lua string.find with the query text.
--- For fuzzy mode: uses the pre-computed match byte offsets from Rust on the
--- target line only, since the fuzzy needle (e.g. "shcema") won't match via
--- literal search against the actual content (e.g. "schema").
--- @param bufnr number Buffer number
--- @param location table Location with .grep_query, .line, optional .col, optional .fuzzy_match_ranges
--- @param namespace number Namespace for extmarks
--- @return table|nil Highlight extmark details for cleanup
function M.highlight_grep_matches(bufnr, location, namespace)
if not vim.api.nvim_buf_is_valid(bufnr) then return nil end
local line_count = vim.api.nvim_buf_line_count(bufnr)
local extmarks = {}
-- Target line highlighting is handled by the native `cursorline` window
-- option, which is enabled on the preview window in grep mode (picker_ui.lua).
-- The cursor is positioned on the target line by preview.scroll_to_line(),
-- giving standard CursorLine background + CursorLineNr line number styling
-- without conflicting with IncSearch match highlights.
-- Fuzzy mode: use pre-computed byte offsets from Rust's match_indices.
-- These are the exact matched character positions within the line, already
-- computed by the SIMD scoring + reference smith-waterman traceback.
-- We only highlight the target line since each fuzzy result has its own
-- unique set of matched positions.
if location.fuzzy_match_ranges and location.line then
local target_line = math.max(1, math.min(location.line, line_count))
for _, range in ipairs(location.fuzzy_match_ranges) do
local start_byte = range[1] -- 0-based byte offset
local end_byte = range[2] -- 0-based exclusive end
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, start_byte, {
end_col = end_byte,
hl_group = 'IncSearch',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
return #extmarks > 0 and extmarks or nil
end
local query = location.grep_query
-- Extract the actual search text from the grep query (strip file constraints like *.rs /src/)
-- The query parser uses space-separated tokens; the first non-constraint token is the pattern.
-- Simple heuristic: strip tokens that look like constraints (start with *, /, or !)
local search_text = query
local parts = vim.split(query, '%s+')
local text_parts = {}
for _, part in ipairs(parts) do
if part ~= '' and not part:match('^[%*!/]') and not part:match('^%.') then table.insert(text_parts, part) end
end
if #text_parts > 0 then search_text = text_parts[1] end
if not search_text or search_text == '' then return nil end
-- Build case-insensitive pattern if the query has no uppercase (smart case)
local has_upper = search_text:match('[A-Z]')
local escaped = vim.pesc(search_text)
-- Highlight pattern occurrences in a window around the target line.
-- Limit to ±200 lines from target to keep it fast for large files.
local scan_start = 1
local scan_end = line_count
if location.line then
scan_start = math.max(1, location.line - 200)
scan_end = math.min(line_count, location.line + 200)
end
local lines = vim.api.nvim_buf_get_lines(bufnr, scan_start - 1, scan_end, false)
for idx, line in ipairs(lines) do
local i = scan_start + idx - 1
local search_line = has_upper and line or line:lower()
local search_pat = has_upper and escaped or escaped:lower()
local start_pos = 1
while true do
local s, e = search_line:find(search_pat, start_pos, true)
if not s then break end
-- s and e are 1-based byte positions; extmarks need 0-based
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, i - 1, s - 1, {
end_col = e,
hl_group = 'IncSearch',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = i - 1 }) end
start_pos = e + 1
end
end
return #extmarks > 0 and extmarks or nil
end
--- Clear location highlights from a buffer
--- @param bufnr number Buffer number
--- @param namespace number Namespace for extmarks
+54 -54
View File
@@ -1,6 +1,3 @@
-- PERF: By default, this plugin initializes itself lazily,
-- so we do not require any modules at the top of this module.
local M = {}
M.state = { initialized = false }
@@ -10,18 +7,51 @@ M.state = { initialized = false }
function M.setup(config) vim.g.fff = config end
--- Find files in current directory
function M.find_files()
--- @param opts? table Optional configuration {renderer = custom_renderer}
function M.find_files(opts)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open()
picker_ui.open(opts)
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
end
end
--- Live grep: search file contents in the current directory
--- @param opts? table Optional configuration overrides
--- @param opts.cwd? string Custom working directory
--- @param opts.title? string Window title (default: "Live Grep")
--- @param opts.prompt? string Input prompt text (default: "grep> ")
--- @param opts.layout? table Layout overrides
--- @param opts.grep? table Grep-specific overrides {max_file_size, smart_case, max_matches_per_file, modes}
--- @param opts.grep.modes? table Available search modes and their cycling order (default: {'plain', 'regex', 'fuzzy'})
function M.live_grep(opts)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if not picker_ok then
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
return
end
local config = require('fff.conf').get()
local grep_renderer = require('fff.grep.grep_renderer')
local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {})
local picker_opts = vim.tbl_deep_extend('force', opts or {}, {
title = (opts and opts.title) or 'Live Grep',
mode = 'grep',
renderer = grep_renderer,
grep_config = grep_config,
})
picker_ui.open(picker_opts)
end
function M.find_in_git_root()
local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '')
if vim.v.shell_error ~= 0 then
local fuzzy = require('fff.core').ensure_initialized()
local ok, git_root = pcall(fuzzy.get_git_root)
if not ok or not git_root then
vim.notify('Not in a git repository', vim.log.levels.WARN)
return
end
@@ -53,8 +83,22 @@ end
--- @return table List of matching files
function M.search(query, max_results)
local fuzzy = require('fff.core').ensure_initialized()
max_results = max_results or require('fff.config').get().max_results
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil)
local config = require('fff.conf').get()
max_results = max_results or config.max_results
local max_threads = config.max_threads or 4
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
local min_combo_count = config.history and config.history.min_combo_count or 3
-- Args: query, max_threads, current_file, combo_boost_score_multiplier, min_combo_count, offset, page_size
local ok, search_result = pcall(
fuzzy.fuzzy_search_files,
query,
max_threads,
nil,
combo_boost_score_multiplier,
min_combo_count,
0,
max_results
)
if ok and search_result.items then return search_result.items end
return {}
end
@@ -116,50 +160,6 @@ function M.get_preview(file_path)
return table.concat(lines, '\n')
end
function M.health_check()
local health = {
ok = true,
messages = {},
}
if not require('fff.core').is_file_picker_initialized() then
health.ok = false
table.insert(health.messages, 'File picker not initialized')
else
table.insert(health.messages, '✓ File picker initialized')
end
local optional_deps = {
{ cmd = 'git', desc = 'Git integration' },
{ cmd = 'chafa', desc = 'Terminal graphics for image preview' },
{ cmd = 'img2txt', desc = 'ASCII art for image preview' },
{ cmd = 'viu', desc = 'Terminal images for image preview' },
}
for _, dep in ipairs(optional_deps) do
if vim.fn.executable(dep.cmd) == 0 then
table.insert(health.messages, string.format('Optional: %s not found (%s)', dep.cmd, dep.desc))
else
table.insert(health.messages, string.format('✓ %s found', dep.cmd))
end
end
if health.ok then
vim.notify('FFF health check passed ✓', vim.log.levels.INFO)
else
vim.notify('FFF health check failed ✗', vim.log.levels.ERROR)
end
for _, message in ipairs(health.messages) do
local level = message:match('^✓') and vim.log.levels.INFO
or message:match('^Optional:') and vim.log.levels.WARN
or vim.log.levels.ERROR
vim.notify(message, level)
end
return health
end
--- Find files in a specific directory
--- @param directory string Directory path to search in
function M.find_files_in_dir(directory)
+1367 -411
View File
File diff suppressed because it is too large Load Diff
-220
View File
@@ -1,220 +0,0 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::git::GitStatusCache;
use git2::Repository;
use notify::RecursiveMode;
use notify_debouncer_mini::{DebounceEventResult, DebouncedEvent, new_debouncer};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{debug, error, info, warn};
type Debouncer = notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>;
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(500);
const MAX_PATHS_THRESHOLD: usize = 50;
impl BackgroundWatcher {
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}",
base_path.display()
);
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
info!("Background file watcher initialized successfully");
Ok(Self {
debouncer: Arc::new(Mutex::new(Some(debouncer))),
})
}
fn create_debouncer(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
) -> Result<Debouncer, Error> {
let mut debouncer = new_debouncer(DEBOUNCE_TIMEOUT, {
move |result: DebounceEventResult| match result {
Ok(events) => {
if !events.is_empty() {
handle_debounced_events(events, &git_workdir);
}
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
})?;
debouncer
.watcher()
.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!("File watcher initizlieed for path: {}", base_path.display());
Ok(debouncer)
}
pub fn stop(&self) {
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
drop(debouncer);
info!("Background file watcher stopped successfully");
} else {
error!("Failed to stop background watcher");
}
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
if let Some(debouncer) = debouncer_guard.take() {
drop(debouncer);
}
} else {
error!("Failed to acquire debouncer lock to drop");
}
}
}
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
debug!("Processing {} debounced events", events.len());
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
let mut need_full_git_rescan = false;
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut files_to_update_git_status = Vec::with_capacity(events.len() * 2);
let mut affected_paths_count = 0usize;
for event in &events {
let path = &event.path;
if is_ignore_definition_path(path) {
info!(
"Detected change in the ignore definition file: {}",
path.display()
);
return trigger_full_rescan(picker);
}
if is_dotgit_change_affecting_status(path, &repo) {
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
continue;
}
debug!("Handling fs event: {:?}", event);
affected_paths_count += 1;
if affected_paths_count > MAX_PATHS_THRESHOLD {
warn!(
"Too many affected paths ({}) in a single batch, triggering full rescan",
affected_paths_count
);
return trigger_full_rescan(picker);
}
if !path.exists() {
picker.remove_file_by_path(path);
continue;
}
let file = picker.on_create_or_modify(path);
if let Some(file) = file {
files_to_update_git_status.push(file.relative_path.clone());
}
}
if need_full_git_rescan {
drop(file_picker_guard); // it's going to be relocked after rescan
info!("Triggering full git rescan by the notification results");
if let Err(e) = FilePicker::refresh_git_status_global() {
error!("Failed to refresh git status: {:?}", e);
}
} else if let Some(repo) = repo.as_ref() {
let status = GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status);
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
}
}
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
if !path.is_file() || is_git_file(path) {
return false;
}
repo.as_ref()
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
}
fn trigger_full_rescan(picker: &mut FilePicker) {
if let Err(e) = picker.trigger_rescan() {
error!("Failed to trigger full rescan: {:?}", e);
}
}
#[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")
)
}
+51 -8
View File
@@ -1,15 +1,34 @@
local download = require('fff.download')
local is_windows = jit.os:lower() == 'windows'
--- @return string
local function get_lib_extension()
if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then return '.dylib' end
if jit.os:lower() == 'windows' then return '.dll' end
if is_windows then return '.dll' end
return '.so'
end
-- search for the lib in the /target/release directory with and without the lib prefix
-- since MSVC doesn't include the prefix
local base_path = debug.getinfo(1).source:match('@?(.*/)')
--- Resolve a path to an absolute, clean form with native separators.
--- Resolves `..` components and on Windows converts forward slashes to
--- backslashes so that Windows APIs (LoadLibraryEx) can find the file.
--- @param path string
--- @return string
local function resolve_path(path)
local resolved = vim.fn.fnamemodify(path, ':p')
if is_windows then resolved = resolved:gsub('/', '\\') end
return resolved
end
-- Determine base_path from the location of this Lua file
local info = debug.getinfo(1, 'S')
-- Match both forward and backslash directory separators for cross-platform support
local base_path = info and info.source and info.source:match('@?(.*[/\\])') or ''
-- Fallback: if base_path is empty, use vim APIs
if not base_path or base_path == '' then
base_path = vim.fn.fnamemodify(vim.fn.resolve(vim.fn.expand('<sfile>:p')), ':h') .. '/'
end
local paths = {
download.get_binary_cpath_component(),
@@ -23,11 +42,35 @@ if cargo_target_dir then
table.insert(paths, cargo_target_dir .. '/release/?' .. get_lib_extension())
end
package.cpath = package.cpath .. ';' .. table.concat(paths, ';')
-- Instead of using require (which can find the wrong lib due to cpath pollution),
-- load the library directly from the first valid path we find
local function try_load_library()
for _, path_pattern in ipairs(paths) do
local actual_path = resolve_path(path_pattern:gsub('%?', 'fff_nvim'))
local stat = vim.uv.fs_stat(actual_path)
if stat and stat.type == 'file' then
local loader, err = package.loadlib(actual_path, 'luaopen_fff_nvim')
if err then return nil, string.format('Error loading library from %s: %s', actual_path, err) end
if loader then return loader() end
end
end
return nil, 'No valid library found in any search path'
end
local ok, backend = pcall(require, 'fff_nvim')
if not ok then
error('Failed to load fff rust backend. Make sure that it has been downloaded or built with `cargo build --release`')
local backend, load_err = try_load_library()
if not backend or load_err then
local resolved = {}
for _, p in ipairs(paths) do
table.insert(resolved, resolve_path(p:gsub('%?', 'fff_nvim')))
end
local err_msg = string.format(
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists or make it exists using \n `:lua require("fff.download").download_or_build_binary()`\nor\n`cargo build --release`\n(and rerun neovim after)',
tostring(load_err),
vim.inspect(resolved)
)
error(err_msg)
end
return backend
-278
View File
@@ -1,278 +0,0 @@
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::frecency::FrecencyTracker;
use mlua::prelude::*;
use once_cell::sync::Lazy;
use std::path::PathBuf;
use std::sync::RwLock;
use std::time::Duration;
mod background_watcher;
mod error;
pub mod file_picker;
mod frecency;
pub mod git;
mod location;
mod path_utils;
pub mod score;
mod tracing;
pub mod types;
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
pub fn init_db(_: &Lua, (db_path, use_unsafe_no_lock): (String, bool)) -> LuaResult<bool> {
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
if frecency.is_some() {
return Ok(false);
}
*frecency = Some(FrecencyTracker::new(&db_path, use_unsafe_no_lock)?);
Ok(true)
}
pub fn destroy_db(_: &Lua, _: ()) -> LuaResult<bool> {
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
*frecency = None;
Ok(true)
}
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
if file_picker.is_some() {
return Ok(false);
}
let picker = FilePicker::new(base_path)?;
*file_picker = Some(picker);
Ok(true)
}
fn reinit_file_picker_internal(path: std::path::PathBuf) -> Result<(), Error> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
// drop should clean it anyway but just to be extra sure
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
}
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
*file_picker = Some(new_picker);
Ok(())
}
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<bool> {
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 = path.canonicalize().map_err(|e| {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
reinit_file_picker_internal(canonical_path)?;
Ok(true)
}
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_mut()
.ok_or_else(|| Error::FilePickerMissing)?;
picker.trigger_rescan()?;
::tracing::info!("scan_files trigger_rescan completed");
Ok(())
}
pub fn fuzzy_search_files(
lua: &Lua,
(query, max_results, max_threads, current_file, order_reverse): (
String,
usize,
usize,
Option<String>,
bool,
),
) -> LuaResult<LuaValue> {
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
let results = FilePicker::fuzzy_search(
picker.get_files(),
&query,
max_results,
max_threads,
current_file.as_deref(),
order_reverse,
);
results.into_lua(lua)
}
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
return Ok(false);
};
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
let file_path = PathBuf::from(&file_path).canonicalize()?;
frecency.track_access(file_path.as_path())?;
picker.update_single_file_frecency(&file_path, frecency)?;
Ok(true)
}
pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::FilePickerMissing)?;
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().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::FilePickerMissing)?;
Ok(picker.is_scan_active())
}
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
FilePicker::refresh_git_status_global().map_err(Into::into)
}
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
return Ok(false);
};
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
picker.update_single_file_frecency(&file_path, frecency)?;
Ok(true)
}
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
picker.stop_background_monitor();
Ok(true)
}
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
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 wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::FilePickerMissing)?;
let timeout_ms = timeout_ms.unwrap_or(500);
let timeout_duration = Duration::from_millis(timeout_ms);
let start_time = std::time::Instant::now();
let mut sleep_duration = Duration::from_millis(1);
while picker.is_scan_active() {
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::tracing::init_tracing(&log_file_path, log_level.as_deref())
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
}
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
let exports = lua.create_table()?;
exports.set("init_db", lua.create_function(init_db)?)?;
exports.set("destroy_db", lua.create_function(destroy_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("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(
"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)?,
)?;
Ok(exports)
}
// https://github.com/mlua-rs/mlua/issues/318
#[mlua::lua_module(skip_memory_check)]
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
create_exports(lua)
}
-279
View File
@@ -1,279 +0,0 @@
use std::path::MAIN_SEPARATOR;
use crate::{
git::is_modified_status,
path_utils::calculate_distance_penalty,
types::{FileItem, Score, ScoringContext},
};
use neo_frizbee::Scoring;
use rayon::prelude::*;
pub fn match_and_score_files<'a>(
files: &'a [FileItem],
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
if context.query.len() < 2 {
return score_all_by_frecency(files, context);
}
if files.is_empty() {
return (vec![], vec![], 0);
}
let has_uppercase_letter = context.query.chars().any(|c| c.is_uppercase());
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(context.max_typos),
sort: false,
scoring: Scoring {
capitalization_bonus: if has_uppercase_letter { 8 } else { 0 },
matching_case_bonus: if has_uppercase_letter { 4 } else { 0 },
..Default::default()
},
};
let query_contains_path_separator = context.query.contains(MAIN_SEPARATOR);
let haystack: Vec<_> = files
.iter()
.map(|f| f.relative_path.to_lowercase())
.collect();
tracing::debug!(
"Starting fuzzy search for query '{}' in {} files",
context.query,
haystack.len()
);
let path_matches = neo_frizbee::match_list(context.query, &haystack, &options);
tracing::debug!(
"Matched {} files for query '{}'",
path_matches.len(),
context.query
);
// assume that filename should only match if the path matches
// we should actually incorporate this bonus by getting this information from neo_frizbee directly
// instead of spawning a separate matching process, but it's okay for the beta
let haystack_of_filenames = path_matches
.par_iter()
.filter_map(|m| {
files
.get(m.index as usize)
.map(|f| f.file_name.to_lowercase())
})
.collect::<Vec<_>>();
// if there is a / in the query we don't even match filenames
let filename_matches = if query_contains_path_separator {
vec![]
} else {
let mut list = neo_frizbee::match_list_parallel(
context.query,
&haystack_of_filenames,
&options,
context.max_threads,
);
list.par_sort_unstable_by_key(|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 = &files[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;
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);
if current_file_penalty < 0 {
tracing::debug!(file =?file.relative_path, ?current_file_penalty, "Applied penalty");
}
let total = base_score
.saturating_add(frecency_boost)
.saturating_add(distance_penalty)
.saturating_add(filename_bonus)
.saturating_add(current_file_penalty);
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,
distance_penalty,
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 => "fuzzy_path",
},
};
(file, score)
})
.collect();
sort_and_truncate(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"
)
}
fn score_all_by_frecency<'a>(
files: &'a [FileItem],
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let results: Vec<_> = files
.par_iter()
.map(|file| {
let total_frecency_score = file.access_frecency_score as i32
+ (file.modification_frecency_score as i32).saturating_mul(4);
let current_file_penalty =
calculate_current_file_penalty(file, total_frecency_score, context);
let total = total_frecency_score.saturating_add(current_file_penalty);
let score = Score {
total,
base_score: 0,
filename_bonus: 0,
distance_penalty: 0,
special_filename_bonus: 0,
current_file_penalty,
frecency_boost: total_frecency_score,
exact_match: false,
match_type: "frecency",
};
(file, score)
})
.collect();
sort_and_truncate(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,
};
tracing::debug!(file =?file.relative_path, current=?context.current_file, ?penalty, "Calculating current file penalty");
}
penalty
}
/// Dynamically sorts and returns the top results either in ascending or descending order
fn sort_and_truncate<'a>(
mut results: Vec<(&'a FileItem, Score)>,
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let total_matched = results.len();
if context.reverse_order {
results.sort_by(|a, b| {
a.1.total
.cmp(&b.1.total)
.then_with(|| a.0.modified.cmp(&b.0.modified))
});
if results.len() > context.max_results {
results.drain(0..(total_matched - context.max_results));
}
} else {
results.sort_by(|a, b| {
b.1.total
.cmp(&a.1.total)
.then_with(|| b.0.modified.cmp(&a.0.modified))
});
results.truncate(context.max_results);
}
let (items, scores) = results.into_iter().unzip();
(items, scores, total_matched)
}
-127
View File
@@ -1,127 +0,0 @@
use mlua::prelude::*;
use std::path::PathBuf;
use crate::{git::format_git_status, location::Location};
#[derive(Debug, Clone)]
pub struct FileItem {
pub path: PathBuf,
pub relative_path: String,
pub file_name: 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>,
}
#[derive(Debug, Clone)]
pub struct Score {
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 exact_match: bool,
pub match_type: &'static str,
}
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
pub query: &'a str,
pub current_file: Option<&'a str>,
pub max_results: usize,
pub max_typos: u16,
pub max_threads: usize,
pub reverse_order: bool,
}
#[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>,
}
impl IntoLua for &FileItem {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("path", self.path.to_string_lossy().to_string())?;
table.set("relative_path", self.relative_path.clone())?;
table.set("name", self.file_name.clone())?;
table.set("size", self.size)?;
table.set("modified", self.modified)?;
table.set("access_frecency_score", self.access_frecency_score)?;
table.set(
"modification_frecency_score",
self.modification_frecency_score,
)?;
table.set("total_frecency_score", self.total_frecency_score)?;
table.set("git_status", format_git_status(self.git_status))?;
Ok(LuaValue::Table(table))
}
}
impl IntoLua for Score {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("total", self.total)?;
table.set("base_score", self.base_score)?;
table.set("filename_bonus", self.filename_bonus)?;
table.set("special_filename_bonus", self.special_filename_bonus)?;
table.set("frecency_boost", self.frecency_boost)?;
table.set("distance_penalty", self.distance_penalty)?;
table.set("current_file_penalty", self.current_file_penalty)?;
table.set("match_type", self.match_type)?;
table.set("exact_match", self.exact_match)?;
Ok(LuaValue::Table(table))
}
}
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))
}
}
impl IntoLua for SearchResult<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("items", self.items)?;
table.set("scores", self.scores)?;
table.set("total_matched", self.total_matched)?;
table.set("total_files", self.total_files)?;
if let Some(location) = &self.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))
}
}
+115
View File
@@ -0,0 +1,115 @@
--- Scrollbar module for pagination indicator
local M = {}
-- Internal state
local scrollbar_state = {
win = nil,
buf = nil,
ever_shown = false,
}
local ns_id = vim.api.nvim_create_namespace('fff_scrollbar')
--- Render the scrollbar to show current page position
--- Creates the window lazily if needed
--- @param layout table Layout info with list_col, list_row, list_width, list_height, show_scrollbar
--- @param config table Config with hl (highlight groups)
--- @param list_win number List window handle
--- @param pagination table Pagination state with page_index, page_size, total_matched
--- @param prompt_position string|nil Prompt position ('top' or 'bottom', defaults to 'bottom')
function M.render(layout, config, list_win, pagination, prompt_position)
if layout.show_scrollbar == false then return end
-- this is the most often path, we don't want to show scrollbar if use doesn't scrolling
if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end
prompt_position = prompt_position or 'bottom'
local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1
local has_multiple_pages = total_pages > 1
local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win)
-- If only one page, hide existing scrollbar and return
if not has_multiple_pages then
if scrollbar_exists then pcall(vim.api.nvim_win_hide, scrollbar_state.win) end
return
end
-- rendering in a separate buffer to overflow the border
if not scrollbar_exists then
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
relative = 'editor',
width = 1,
height = layout.list_height,
col = layout.list_col + layout.list_width + 1,
row = layout.list_row + 1,
border = 'none',
style = 'minimal',
focusable = false,
})
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
scrollbar_state.ever_shown = true
end
if not scrollbar_state.buf or not vim.api.nvim_buf_is_valid(scrollbar_state.buf) then return end
pcall(vim.api.nvim_win_set_config, scrollbar_state.win, { hide = false })
local win_height = vim.api.nvim_win_get_height(list_win)
local thumb_size = math.max(1, math.floor(win_height / total_pages))
local scrollbar_range = win_height - thumb_size
-- inverse the scrollbar when the position is at the bottom
local thumb_start
if prompt_position == 'bottom' then
thumb_start =
math.floor(((total_pages - 1 - pagination.page_index) / math.max(1, total_pages - 1)) * scrollbar_range)
else
thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
end
local lines = {}
for i = 1, win_height do
if i >= thumb_start + 1 and i < thumb_start + thumb_size + 1 then
table.insert(lines, '') -- Thick block for thumb
else
table.insert(lines, '') -- Thin line for track
end
end
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
if thumb_size > 0 then
pcall(vim.api.nvim_buf_set_extmark, scrollbar_state.buf, ns_id, thumb_start, 0, {
end_row = thumb_start + thumb_size,
end_col = 0,
hl_group = config.hl.scrollbar,
hl_eol = true,
})
end
end
function M.cleanup()
if scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) then
pcall(vim.api.nvim_win_close, scrollbar_state.win, true)
end
if scrollbar_state.buf and vim.api.nvim_buf_is_valid(scrollbar_state.buf) then
pcall(vim.api.nvim_buf_delete, scrollbar_state.buf, { force = true })
end
scrollbar_state.win = nil
scrollbar_state.buf = nil
scrollbar_state.ever_shown = false
end
return M
+117
View File
@@ -0,0 +1,117 @@
--- Treesitter Highlight Extraction
--- Extracts syntax highlights from a code string using treesitter.
--- Uses a per-language scratch buffer pool to avoid repeated buffer creation.
--- Results are returned as extmark-style tables { col, end_col, hl_group }.
local M = {}
--- Per-language scratch buffer cache
--- @type table<string, number>
local scratch_bufs = {}
--- Get or create a scratch buffer for a given treesitter language.
--- The buffer is reused across calls — content is overwritten each time.
--- @param lang string Treesitter language name
--- @return number buf Buffer handle
local function get_scratch_buf(lang)
local buf = scratch_bufs[lang]
if buf and vim.api.nvim_buf_is_valid(buf) then return buf end
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(buf, 'fff://treesitter/' .. lang)
vim.bo[buf].bufhidden = 'hide'
vim.bo[buf].buftype = 'nofile'
vim.bo[buf].swapfile = false
vim.bo[buf].undolevels = -1
scratch_bufs[lang] = buf
return buf
end
--- Resolve a filename to a treesitter language.
--- Returns nil if no parser is available.
--- @param filename string File name (e.g. "foo.rs")
--- @return string|nil lang Treesitter language name, or nil
function M.lang_from_filename(filename)
if not filename or filename == '' then return nil end
-- Use vim.filetype.match to get the filetype from the filename
local ok, ft = pcall(vim.filetype.match, { filename = filename })
if not ok or not ft then return nil end
-- Convert filetype to treesitter language
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
if not lang_ok or not lang then lang = ft end
-- Check if the parser is actually installed
local has_parser = pcall(vim.treesitter.language.add, lang)
if not has_parser then return nil end
return lang
end
--- Extract treesitter highlights for a single line of code.
--- Returns an array of { col, end_col, hl_group } tables where col/end_col
--- are 0-based byte offsets within the input string.
---
--- @param text string The line of code to highlight
--- @param lang string Treesitter language name (from lang_from_filename)
--- @return table[] highlights Array of { col: number, end_col: number, hl_group: string }
function M.get_line_highlights(text, lang)
if not text or text == '' or not lang then return {} end
local buf = get_scratch_buf(lang)
-- Write the single line into the scratch buffer
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text })
vim.bo[buf].modifiable = false
-- Parse with treesitter
local ok, parser = pcall(vim.treesitter.get_parser, buf, lang)
if not ok or not parser then return {} end
local parse_ok = pcall(parser.parse, parser, true)
if not parse_ok then return {} end
local highlights = {}
parser:for_each_tree(function(tstree, tree)
if not tstree then return end
local root = tstree:root()
if not root then return end
local tree_lang = tree:lang()
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
if not query_ok or not query then return end
for capture, node, metadata in query:iter_captures(root, buf, 0, 1) do
local name = query.captures[capture]
if name and name ~= 'spell' and name ~= 'conceal' then
local start_row, start_col, end_row, end_col = node:range()
-- Only process highlights on line 0 (our single line)
if start_row == 0 then
if end_row > 0 then end_col = #text end -- multi-line node: clamp to line end
if start_col < end_col then
highlights[#highlights + 1] = {
col = start_col,
end_col = end_col,
hl_group = '@' .. name .. '.' .. tree_lang,
}
end
end
end
end
end)
return highlights
end
--- Clean up all scratch buffers.
--- Called when the picker closes.
function M.cleanup()
for lang, buf in pairs(scratch_bufs) do
if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end
scratch_bufs[lang] = nil
end
end
return M
+33
View File
@@ -0,0 +1,33 @@
local M = {}
function M.mkdir_recursive(path, callback)
vim.uv.fs_stat(path, function(err, stat)
if not err and stat then
callback(true, nil)
return
end
local parent = vim.fn.fnamemodify(path, ':h')
if parent == path or parent == '' or parent == '.' then
callback(false, 'Cannot create root directory')
return
end
M.mkdir_recursive(parent, function(parent_ok, parent_err)
if not parent_ok then
callback(false, parent_err)
return
end
vim.uv.fs_mkdir(path, 493, function(mkdir_err) -- 493 = 0755 octal
if mkdir_err and not mkdir_err:match('EEXIST') then
callback(false, 'Failed to create directory: ' .. mkdir_err)
return
end
callback(true, nil)
end)
end)
end)
end
return M
@@ -0,0 +1,18 @@
{
"name": "@ff-labs/fff-bun-darwin-arm64",
"version": "0.0.0",
"description": "fff native binary for macOS ARM64 (Apple Silicon)",
"os": ["darwin"],
"cpu": ["arm64"],
"main": "libfff_c.dylib",
"files": ["libfff_c.dylib"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-darwin-arm64"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@ff-labs/fff-bun-darwin-x64",
"version": "0.0.0",
"description": "fff native binary for macOS x64 (Intel)",
"os": ["darwin"],
"cpu": ["x64"],
"main": "libfff_c.dylib",
"files": ["libfff_c.dylib"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-darwin-x64"
}
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bun-linux-arm64-gnu",
"version": "0.0.0",
"description": "fff native binary for Linux ARM64 (glibc)",
"os": ["linux"],
"cpu": ["arm64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-arm64-gnu"
},
"libc": ["glibc"]
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bun-linux-arm64-musl",
"version": "0.0.0",
"description": "fff native binary for Linux ARM64 (musl)",
"os": ["linux"],
"cpu": ["arm64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-arm64-musl"
},
"libc": ["musl"]
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bun-linux-x64-gnu",
"version": "0.0.0",
"description": "fff native binary for Linux x64 (glibc)",
"os": ["linux"],
"cpu": ["x64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-x64-gnu"
},
"libc": ["glibc"]
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bun-linux-x64-musl",
"version": "0.0.0",
"description": "fff native binary for Linux x64 (musl)",
"os": ["linux"],
"cpu": ["x64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-x64-musl"
},
"libc": ["musl"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@ff-labs/fff-bun-win32-arm64",
"version": "0.0.0",
"description": "fff native binary for Windows ARM64",
"os": ["win32"],
"cpu": ["arm64"],
"main": "fff_c.dll",
"files": ["fff_c.dll"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-win32-arm64"
}
}

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