Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac8df4c9e4 | |||
| e3e534f4ad | |||
| 764e3ecf18 | |||
| 10a27f9678 | |||
| 66e712e066 | |||
| c5a3f89c5e | |||
| 29f81a141f | |||
| d2e3993398 | |||
| a411100fa4 | |||
| 0a993d7a30 | |||
| 7c2d46f1e2 | |||
| c3ed9fb17b | |||
| 0f40c66eb7 | |||
| 7298978bcb | |||
| 8f69f987a4 | |||
| 3e9b8655b7 | |||
| 727935ede6 | |||
| 9a2612b1b5 | |||
| 7c0d999144 | |||
| 81d98f6b9a | |||
| 46e87e5928 | |||
| 9bc928db65 | |||
| 434344f6e9 | |||
| 6a3e481175 | |||
| 29e13ac3d4 | |||
| c9137b19b6 | |||
| 00019beb0c | |||
| d7bc72786d | |||
| 9a6d8ca81a | |||
| 6455ce7c68 | |||
| 0523fe39ff | |||
| 6b01f95ca6 | |||
| 5ab271ea9d | |||
| 448cf3d025 | |||
| 3cc7da787f | |||
| 8b1f3f4e95 | |||
| ca4c32d364 | |||
| f6af8353c3 | |||
| b384bf7dad | |||
| 2951756ae3 | |||
| c17056bcb6 | |||
| abfa5d0ef7 | |||
| d997344fd7 | |||
| e3ba972db6 | |||
| a4f87bd4f2 | |||
| 7fd361a369 | |||
| c477f12487 | |||
| 8fe26ad4bd | |||
| 003c05cf6d | |||
| 0312492570 | |||
| e3f788f87b | |||
| 53acaf90ab | |||
| 2c55114048 | |||
| d88922e6c7 | |||
| 9edf195c8f | |||
| 65aeacf9e2 | |||
| e8850c3c62 | |||
| ee8bd6e839 | |||
| 3c76ba523f | |||
| 51f32597de | |||
| 2df06289c9 | |||
| 550a9053f9 | |||
| 3fa36f0a75 | |||
| 61081a55ca | |||
| 3a803c40ed | |||
| 4a3453d3de | |||
| cac2ec7130 | |||
| 5a898066d2 | |||
| 7cdc71d5a1 | |||
| 54f96dade0 | |||
| 56ea1404f1 | |||
| 43100e3150 | |||
| ac7cd106b3 | |||
| d27a9bc8fd | |||
| 8905981871 | |||
| 381992457b | |||
| a9b2f5bba2 | |||
| 61c3cc7420 | |||
| 829e05b353 | |||
| a9cac80ea9 | |||
| 7b1fab33be | |||
| 25dfbd2ffd | |||
| c26a2bb375 | |||
| 84a8ebc230 | |||
| a42ba4907d | |||
| 601b150eee | |||
| 13b855b172 | |||
| 13e1383ffd | |||
| fd38df7bf0 | |||
| e6e58f91d2 | |||
| c3271624be | |||
| ad12110ded | |||
| 7c8a941001 | |||
| 4e1a94c9e5 | |||
| 16b69da3b7 | |||
| 06066d138d | |||
| b0edf2a0d1 | |||
| 80263ad595 | |||
| 3396c03f4a | |||
| bda834e34d | |||
| afedc064bd | |||
| edfc02c024 | |||
| d25abb8a73 | |||
| e1c3db33ac | |||
| 8ce96885b4 | |||
| 38c533bcce | |||
| b2d3906577 | |||
| a614094de9 | |||
| 2ff3311f99 | |||
| 547a654ccc | |||
| 3fd35a40fa | |||
| 7b046a7f44 | |||
| 056e6c9fdf | |||
| 8c14d30f5c | |||
| eb619749ab | |||
| 9e51132156 | |||
| 7efaceb7ec | |||
| 0e75b1c1db | |||
| 4a1297dbb8 | |||
| b5807d5efd | |||
| 6f380b2982 | |||
| 69a1587c10 | |||
| f9ee0a20d8 | |||
| 4b31ac8311 | |||
| 7fffe4f670 | |||
| be94f5aea2 | |||
| cdd38155fc | |||
| 3ee2cb116f | |||
| f4a5692c43 | |||
| fb280b0be6 | |||
| d3e34a7802 | |||
| 5607e61ef7 | |||
| 76bd9a1509 | |||
| aa8d56ea91 | |||
| a75fe334db | |||
| 3aafc930ba |
+4
-15
@@ -1,10 +1,4 @@
|
||||
[target.x86_64-apple-darwin]
|
||||
rustflags = [
|
||||
"-C", "link-arg=-undefined",
|
||||
"-C", "link-arg=dynamic_lookup",
|
||||
]
|
||||
|
||||
[target.aarch64-apple-darwin]
|
||||
[target.'cfg(target_os = "macos")']
|
||||
rustflags = [
|
||||
"-C", "link-arg=-undefined",
|
||||
"-C", "link-arg=dynamic_lookup",
|
||||
@@ -16,12 +10,7 @@ rustflags = ["-C", "target-feature=-crt-static"]
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
rustflags = ["-C", "target-feature=-crt-static"]
|
||||
|
||||
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
|
||||
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
|
||||
[target.aarch64-linux-android]
|
||||
rustflags = [
|
||||
"-C",
|
||||
"linker=aarch64-linux-android-clang",
|
||||
"-C",
|
||||
"link-args=-rdynamic",
|
||||
"-C",
|
||||
"default-linker-libraries",
|
||||
]
|
||||
rustflags = ["-C", "link-args=-rdynamic"]
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
name: e2e Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
MACOSX_DEPLOYMENT_TARGET: "13"
|
||||
|
||||
jobs:
|
||||
lua-tests:
|
||||
name: e2e (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
# e2e tests could be flaky on CI so we do not block release creation if they failed
|
||||
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- os: macos-latest
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
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 --features zlob
|
||||
|
||||
- name: Copy binary to target/release (Windows)
|
||||
if: matrix.target
|
||||
shell: bash
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
|
||||
|
||||
- name: Verify Windows DLL has no unexpected dependencies
|
||||
if: matrix.target
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
|
||||
$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
|
||||
$dumpbin = Get-ChildItem "$vsPath" -Recurse -Filter "dumpbin.exe" | Select-Object -First 1
|
||||
if (-not $dumpbin) { Write-Error "dumpbin.exe not found"; exit 1 }
|
||||
|
||||
$deps = & $dumpbin.FullName /DEPENDENTS target\release\fff_nvim.dll | Out-String
|
||||
Write-Host $deps
|
||||
# zlob must be statically linked - fail if zlob.dll appears as a dependency
|
||||
if ($deps -match 'zlob\.dll') {
|
||||
Write-Error "fff_nvim.dll has unexpected dynamic dependency on zlob.dll - zlob should be statically linked"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build Rust binary
|
||||
if: ${{ !matrix.target }}
|
||||
run: cargo build --release -p fff-nvim --features zlob
|
||||
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
with:
|
||||
neovim: true
|
||||
version: v0.10.4
|
||||
|
||||
- name: Clone plenary.nvim
|
||||
shell: bash
|
||||
run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ../plenary.nvim
|
||||
|
||||
- name: Run Lua tests
|
||||
shell: bash
|
||||
run: make test-lua
|
||||
|
||||
- name: Run bun tests
|
||||
shell: bash
|
||||
run: make test-bun
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Lua CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lua-ls:
|
||||
name: lua-language-server type check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Neovim
|
||||
run: |
|
||||
curl -L https://github.com/neovim/neovim/releases/download/v0.11.5/nvim-linux-x86_64.tar.gz -o /opt/nvim.tar.gz
|
||||
mkdir /opt/nvim
|
||||
tar xzf /opt/nvim.tar.gz -C /opt/nvim
|
||||
mv /opt/nvim/nvim-linux-x86_64/* /opt/nvim
|
||||
echo "/opt/nvim/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install lua-language-server
|
||||
run: |
|
||||
curl -L "https://github.com/LuaLS/lua-language-server/releases/download/3.17.1/lua-language-server-3.17.1-linux-x64.tar.gz" -o /opt/lls.tar.gz
|
||||
mkdir /opt/lls
|
||||
tar -xzf /opt/lls.tar.gz -C /opt/lls
|
||||
echo "/opt/lls/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Clone snacks.nvim
|
||||
run: git clone --depth=1 https://github.com/folke/snacks.nvim /opt/snacks.nvim
|
||||
|
||||
- name: Run lua-language-server
|
||||
run: lua-language-server --configpath .luarc.ci.json --check=.
|
||||
|
||||
luacheck:
|
||||
name: luacheck lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install luacheck
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y luarocks
|
||||
sudo luarocks install luacheck
|
||||
|
||||
- name: Run luacheck
|
||||
run: luacheck lua/
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Nix CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
- uses: DeterminateSystems/flake-checker-action@main
|
||||
|
||||
- name: Run `nix flake check`
|
||||
run: nix flake check
|
||||
|
||||
- name: Run `nix build`
|
||||
run: nix build
|
||||
|
||||
- name: Run `nix build .#fff-nvim`
|
||||
run: nix build .#fff-nvim
|
||||
|
||||
- name: Run `nix run .#release`
|
||||
run: nix run .#release
|
||||
@@ -1,6 +1,6 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
branches-ignore:
|
||||
- main
|
||||
name: docs
|
||||
|
||||
@@ -11,17 +11,25 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch last 2 commits required for auto force push back
|
||||
fetch-depth: 2
|
||||
|
||||
- name: panvimdoc
|
||||
uses: kdheepak/panvimdoc@main
|
||||
with:
|
||||
vimdoc: fff.nvim
|
||||
version: "Neovim >= 0.8.0"
|
||||
version: "Neovim >= 0.10.0"
|
||||
demojify: true
|
||||
treesitter: true
|
||||
- name: Push changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
|
||||
- name: Get last commit message
|
||||
id: last-commit
|
||||
run: |
|
||||
echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
echo "author=$(git log -1 --pretty=\"%an <%ae>\")" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v6
|
||||
with:
|
||||
commit_message: "chore: autgenerate vimdoc"
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
commit_author: ${{ steps.last-commit.outputs.author }}
|
||||
commit_message: "chore: Update docs for - ${{ steps.last-commit.outputs.message }}"
|
||||
|
||||
+418
-46
@@ -1,13 +1,128 @@
|
||||
name: Release
|
||||
name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches: [main, feat/interchangable-ffi, feat/termux]
|
||||
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
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@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') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nvim-${{ matrix.target }}
|
||||
path: ${{ matrix.target }}.*
|
||||
|
||||
build-c:
|
||||
name: Build C FFI ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,104 +130,361 @@ 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_fuzzy.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_fuzzy.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_fuzzy.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_fuzzy.so
|
||||
# Android (Termux)
|
||||
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
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_fuzzy.so
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_c.so
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_fuzzy.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_fuzzy.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_fuzzy.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_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')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo install cross --git https://github.com/cross-rs/cross
|
||||
cross build --release --target ${{ matrix.target }}
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.so"
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
# Ventura (https://en.wikipedia.org/wiki/MacOS_version_history#Releases)
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }}
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.dylib"
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.dll"
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
- name: Prepare npm package
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy the built binary into the platform npm package directory
|
||||
cp "c-lib-${{ matrix.target }}.${{ matrix.ext }}" "packages/${{ matrix.npm_package }}/${{ matrix.lib_filename }}"
|
||||
|
||||
- name: Upload C library artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: ${{ matrix.target }}.*
|
||||
name: c-lib-${{ matrix.target }}
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
- name: Upload npm package artifact
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
path: packages/${{ matrix.npm_package }}/
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: build
|
||||
needs: [build-nvim, build-c]
|
||||
runs-on: ubuntu-latest
|
||||
# do not create releases on the forks (no permissions)
|
||||
if: github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./binaries
|
||||
|
||||
- name: Flatten and rename Neovim artifacts
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
# Move nvim artifacts to root level with original naming
|
||||
for dir in nvim-*/; do
|
||||
target="${dir#nvim-}"
|
||||
target="${target%/}"
|
||||
for file in "$dir"*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
mv "$file" "./$filename"
|
||||
fi
|
||||
done
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Flatten C library artifacts
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
# Move c-lib artifacts to root level
|
||||
for dir in c-lib-*/; do
|
||||
for file in "$dir"*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
mv "$file" "./$filename"
|
||||
fi
|
||||
done
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Remove npm package artifacts from release binaries
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
rm -rf npm-*
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
for file in ./**/*; do
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
ls -la
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" == *.sha256 ]]; then
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Prepare tag
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
sha="$(git rev-parse --short HEAD)"
|
||||
echo "tag=$sha" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: "${{ steps.vars.outputs.tag }}"
|
||||
tag_name: "${{ steps.vars.outputs.tag }}"
|
||||
token: ${{ github.token }}
|
||||
files: ./**/*
|
||||
files: ./binaries/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
prerelease: true
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
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/interchangable-ffi'))
|
||||
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/interchangable-ffi'))
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
# comments doesn't work on forks
|
||||
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
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 }}*
|
||||
|
||||
@@ -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,28 @@ 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 -p fff-core -p fff-query-parser -p fff-c --features zlob
|
||||
cargo test --verbose -p grep-searcher
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
@@ -50,10 +59,20 @@ 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
|
||||
run: |
|
||||
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
|
||||
cargo clippy -p grep-searcher -- -D warnings
|
||||
|
||||
@@ -10,3 +10,6 @@ result
|
||||
.repro/
|
||||
.wrangler/
|
||||
*.so
|
||||
big-repo/
|
||||
# all the perf like utility files
|
||||
*.data
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- luacheck configuration for fff.nvim
|
||||
-- https://luacheck.readthedocs.io/en/stable/config.html
|
||||
|
||||
-- Neovim globals
|
||||
globals = { "vim" }
|
||||
|
||||
-- Standard library
|
||||
std = "luajit"
|
||||
|
||||
-- Ignore line length (handled by stylua)
|
||||
max_line_length = false
|
||||
|
||||
-- Ignore unused self argument in methods
|
||||
self = false
|
||||
|
||||
-- Files/directories to ignore
|
||||
exclude_files = {
|
||||
".luarocks/",
|
||||
}
|
||||
|
||||
-- Warn about unused variables, but allow _ prefix convention
|
||||
unused_args = true
|
||||
ignore = {
|
||||
"212", -- unused argument (too noisy for callback-heavy code)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
|
||||
"runtime": {
|
||||
"version": "LuaJIT",
|
||||
"pathStrict": true
|
||||
},
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/opt/nvim/share/nvim/runtime/lua/vim/_meta",
|
||||
"/opt/nvim/share/nvim/runtime/lua/vim/shared.lua",
|
||||
"${3rd}/luv/library",
|
||||
"${3rd}/busted/library",
|
||||
"/opt/snacks.nvim/lua"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"severity": {
|
||||
"undefined-global": "Error",
|
||||
"undefined-field": "Warning",
|
||||
"missing-return": "Warning",
|
||||
"redundant-parameter": "Warning",
|
||||
"param-type-mismatch": "Warning",
|
||||
"assign-type-mismatch": "Warning",
|
||||
"cast-type-mismatch": "Warning",
|
||||
"deprecated": "Warning",
|
||||
"undefined-doc-param": "Warning"
|
||||
},
|
||||
"neededFileStatus": {
|
||||
"undefined-global": "Any",
|
||||
"undefined-field": "Any",
|
||||
"missing-return": "Any",
|
||||
"redundant-parameter": "Any",
|
||||
"param-type-mismatch": "Any",
|
||||
"assign-type-mismatch": "Any",
|
||||
"cast-type-mismatch": "Any",
|
||||
"deprecated": "Any",
|
||||
"undefined-doc-param": "Any"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"checkTableShape": true
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
|
||||
"runtime": {
|
||||
"version": "LuaJIT"
|
||||
},
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
|
||||
"${3rd}/luv/library",
|
||||
"${3rd}/busted/library"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"severity": {
|
||||
"undefined-global": "Error",
|
||||
"undefined-field": "Warning",
|
||||
"missing-return": "Warning",
|
||||
"redundant-parameter": "Warning",
|
||||
"param-type-mismatch": "Warning",
|
||||
"assign-type-mismatch": "Warning",
|
||||
"cast-type-mismatch": "Warning",
|
||||
"deprecated": "Warning",
|
||||
"undefined-doc-param": "Warning"
|
||||
},
|
||||
"neededFileStatus": {
|
||||
"undefined-global": "Any",
|
||||
"undefined-field": "Any",
|
||||
"missing-return": "Any",
|
||||
"redundant-parameter": "Any",
|
||||
"param-type-mismatch": "Any",
|
||||
"assign-type-mismatch": "Any",
|
||||
"cast-type-mismatch": "Any",
|
||||
"deprecated": "Any",
|
||||
"undefined-doc-param": "Any"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"checkTableShape": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
empty_config.lua
|
||||
benches/
|
||||
doc/
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
syntax = "LuaJIT"
|
||||
column_width = 120
|
||||
line_endings = "Unix"
|
||||
indent_type = "Spaces"
|
||||
|
||||
Generated
+745
-48
File diff suppressed because it is too large
Load Diff
+43
-19
@@ -1,30 +1,54 @@
|
||||
[package]
|
||||
name = "fff_nvim"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
[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"
|
||||
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
bindet = "0.3"
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
git2 = "0.20.2"
|
||||
ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
dunce = "1.0"
|
||||
# git2 - base config without TLS (each crate adds platform-specific TLS)
|
||||
git2 = { version = "0.20.2", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
globset = "0.4"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-searcher = { path = "crates/fff-searcher" }
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
zlob = "1.3.0"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.6.0" }
|
||||
neo_frizbee = "0.8.1"
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.5"
|
||||
notify-debouncer-full = "0.7"
|
||||
once_cell = "1.20.2"
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,38 @@
|
||||
PLENARY_DIR ?= ../plenary.nvim
|
||||
|
||||
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
|
||||
|
||||
build:
|
||||
cargo build --release --features zlob
|
||||
|
||||
test-setup:
|
||||
@if [ ! -d "$(PLENARY_DIR)" ]; then \
|
||||
echo "Cloning plenary.nvim..."; \
|
||||
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(PLENARY_DIR); \
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --workspace --features zlob
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
|
||||
prepare-bun: build
|
||||
mkdir -p packages/fff-bun/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null; \
|
||||
true
|
||||
|
||||
test-bun: prepare-bun
|
||||
cd packages/fff-bun && bun test src/
|
||||
|
||||
test: test-rust test-lua test-bun
|
||||
|
||||
format-rust:
|
||||
cargo fmt --all
|
||||
format-lua:
|
||||
stylua .
|
||||
|
||||
format: format-rust format-lua
|
||||
@@ -11,11 +11,10 @@
|
||||
<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.
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
|
||||
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
|
||||
|
||||
@@ -23,15 +22,16 @@ It comes with a dedicated rust backend runtime that keep tracks of the file inde
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- [Typo resistant fuzzy search](https://github.com/saghen/frizbee)
|
||||
- Git status integration allowing to take adavantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicaged backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
|
||||
## Installation
|
||||
|
||||
> [!NOTE]
|
||||
> Although we'll try to make sure to keep 100% backward compatibiility, by using you should understand that silly bugs and breaking changes may happen.
|
||||
> [!NOTE]
|
||||
> Although we'll try to make sure to keep 100% backward compatibility, by using you should understand that silly bugs and breaking changes may happen.
|
||||
> And also we hope for your contributions and feedback to make this plugin ideal for everyone.
|
||||
|
||||
### Prerequisites
|
||||
@@ -39,99 +39,268 @@ It comes with a dedicated rust backend runtime that keep tracks of the file inde
|
||||
FFF.nvim requires:
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- Rust toolchain (requires nightly for building the native backend)
|
||||
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
|
||||
### Package Managers
|
||||
### Installation
|
||||
|
||||
#### lazy.nvim
|
||||
|
||||
```lua
|
||||
{
|
||||
"dmtrKovalenko/fff.nvim",
|
||||
build = "cargo build --release",
|
||||
opts = {
|
||||
-- pass here all the options
|
||||
},
|
||||
keys = {
|
||||
{
|
||||
"<leader>ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function()
|
||||
require("fff").toggle()
|
||||
end,
|
||||
desc = "Toggle FFF",
|
||||
'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,
|
||||
-- if you are using nixos
|
||||
-- build = "nix run .#release",
|
||||
opts = { -- (optional)
|
||||
debug = {
|
||||
enabled = true, -- we expect your collaboration at least during the beta
|
||||
show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
|
||||
},
|
||||
},
|
||||
-- No need to lazy-load with lazy.nvim.
|
||||
-- This plugin initializes itself lazily.
|
||||
lazy = false,
|
||||
keys = {
|
||||
{
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
},
|
||||
{
|
||||
"fg",
|
||||
function() require('fff').live_grep() end,
|
||||
desc = 'LiFFFe grep',
|
||||
},
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Default Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
|
||||
#### vim.pack
|
||||
|
||||
```lua
|
||||
require("fff").setup({
|
||||
-- UI dimensions and appearance
|
||||
width = 0.8, -- Window width as fraction of screen
|
||||
height = 0.8, -- Window height as fraction of screen
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
preview = {
|
||||
enabled = true,
|
||||
width = 0.5,
|
||||
max_lines = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
|
||||
|
||||
-- Key mappings (supports both single keys and arrays for multiple bindings)
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
|
||||
-- Highlight groups
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
|
||||
-- Debug options
|
||||
debug = {
|
||||
show_scores = false, -- Toggle with F2 or :FFFDebug
|
||||
},
|
||||
vim.api.nvim_create_autocmd('PackChanged', {
|
||||
callback = function(event)
|
||||
if event.data.updated then
|
||||
require('fff.download').download_or_build_binary()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- the plugin will automatically lazy load
|
||||
vim.g.fff = {
|
||||
lazy_sync = true, -- start syncing only when the picker is open
|
||||
debug = {
|
||||
enabled = true,
|
||||
show_scores = true,
|
||||
},
|
||||
}
|
||||
|
||||
vim.keymap.set(
|
||||
'n',
|
||||
'ff',
|
||||
function() require('fff').find_files() end,
|
||||
{ desc = 'FFFind files' }
|
||||
)
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete configuration with all available options:
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '🪿 ',
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
|
||||
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
#### Multiple Key Bindings
|
||||
|
||||
You can assign multiple key combinations to the same action:
|
||||
#### Available Methods
|
||||
|
||||
```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
|
||||
}
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
```
|
||||
|
||||
#### Commands
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
- `:FFFHealth` - Check FFF health status and dependencies
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
#### Multiline Paste Support
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
|
||||
@@ -144,58 +313,186 @@ Toggle scoring information display:
|
||||
- Use `:FFFDebug` command
|
||||
- Enable by default with `debug.show_scores = true`
|
||||
|
||||
````
|
||||
#### Multi-Select and Quickfix Integration
|
||||
|
||||
#### vim-plug
|
||||
Select multiple files and send them to Neovim's quickfix list (keymaps are configurable):
|
||||
|
||||
```vim
|
||||
Plug 'MunifTanjim/nui.nvim'
|
||||
Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' }
|
||||
````
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
## Configuration
|
||||
#### Live Grep Search Modes
|
||||
|
||||
### Default Configuration
|
||||
Live grep supports three search modes, cycled with `<S-Tab>`:
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
|
||||
- **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({
|
||||
-- UI dimensions and appearance
|
||||
width = 0.8, -- Window width as fraction of screen
|
||||
height = 0.8, -- Window height as fraction of screen
|
||||
preview_width = 0.5, -- Preview pane width as fraction of picker
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
|
||||
debug = {
|
||||
show_scores = true, -- We hope for your collaboratio
|
||||
},
|
||||
require('fff').setup({
|
||||
grep = {
|
||||
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Per-call configuration:**
|
||||
|
||||
```lua
|
||||
-- Only fuzzy and plain modes for this specific grep
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Single mode (hides mode indicator completely)
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Pre-fill the search with an initial query
|
||||
require('fff').live_grep({ query = 'search term' })
|
||||
```
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
|
||||
|
||||
#### 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
|
||||
|
||||
Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will verify:
|
||||
|
||||
- File picker initialization status
|
||||
- Optional dependencies (git, image preview tools)
|
||||
- Database connectivity
|
||||
|
||||
#### Viewing Logs
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
```vim
|
||||
:FFFOpenLog
|
||||
```
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
|
||||
|
||||
#### Common Issues
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it's properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[files]
|
||||
extend-exclude = ["/CHANGELOG.md"]
|
||||
extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
|
||||
|
||||
[default.extend-words]
|
||||
noice = "noice"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
fn main() {
|
||||
// delete existing version file created by downloader
|
||||
let _ = std::fs::remove_file("target/release/version");
|
||||
|
||||
// get current sha from git
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let sha = String::from_utf8(output.stdout).unwrap();
|
||||
|
||||
// write to version
|
||||
std::fs::create_dir_all("target/release").unwrap();
|
||||
std::fs::write("target/release/version", sha.trim()).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[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"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[dependencies]
|
||||
mimalloc.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"
|
||||
@@ -0,0 +1,352 @@
|
||||
//! 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, c_void};
|
||||
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,
|
||||
/// Opaque handle pointer (used by fff_create to return the instance)
|
||||
pub handle: *mut c_void,
|
||||
}
|
||||
|
||||
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(),
|
||||
handle: 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(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a successful result carrying an opaque instance handle.
|
||||
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
|
||||
Box::into_raw(Box::new(FffResult {
|
||||
success: true,
|
||||
data: ptr::null_mut(),
|
||||
error: ptr::null_mut(),
|
||||
handle,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.matches.len(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
//! 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.).
|
||||
//!
|
||||
//! # Instance-based API
|
||||
//!
|
||||
//! All state is owned by an opaque `FffInstance` fff_handle. Callers create an instance
|
||||
//! with `fff_create`, pass the fff_handle to every subsequent call, and free it with
|
||||
//! `fff_destroy`. Multiple independent instances can coexist in the same process.
|
||||
//!
|
||||
//! # Memory management
|
||||
//!
|
||||
//! * Every `fff_*` function that returns `*mut FffResult` requires the caller to
|
||||
//! free the result with `fff_free_result`.
|
||||
//! * The instance itself must be freed with `fff_destroy`.
|
||||
|
||||
use std::ffi::{CStr, CString, c_char, c_void};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
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::{SharedFrecency, SharedPicker};
|
||||
use ffi_types::{FffResult, GrepSearchOptionsJson, InitOptions, ScanProgress, SearchOptions};
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
/// Opaque fff_handle holding all per-instance state.
|
||||
///
|
||||
/// The caller receives this as `*mut c_void` and must pass it to every FFI call.
|
||||
/// The fff_handle is freed by `fff_destroy`.
|
||||
struct FffInstance {
|
||||
picker: SharedPicker,
|
||||
frecency: SharedFrecency,
|
||||
query_tracker: Arc<RwLock<Option<QueryTracker>>>,
|
||||
}
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover a `&FffInstance` from the opaque pointer.
|
||||
///
|
||||
/// Returns an error `FffResult` if the pointer is null.
|
||||
unsafe fn instance_ref<'a>(fff_handle: *mut c_void) -> Result<&'a FffInstance, *mut FffResult> {
|
||||
if fff_handle.is_null() {
|
||||
Err(FffResult::err(
|
||||
"Instance handle is null. Create one with fff_create first.",
|
||||
))
|
||||
} else {
|
||||
Ok(unsafe { &*(fff_handle as *const FffInstance) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new file finder instance.
|
||||
///
|
||||
/// Returns an opaque pointer that must be passed to all other `fff_*` calls
|
||||
/// and eventually freed with `fff_destroy`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `opts_json` must be a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_create(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)),
|
||||
};
|
||||
|
||||
// Create shared state that background threads will write into.
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let query_tracker: Arc<RwLock<Option<QueryTracker>>> = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize frecency tracker if path is provided
|
||||
if let Some(frecency_path) = opts.frecency_db_path {
|
||||
if let Some(parent) = PathBuf::from(&frecency_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match FrecencyTracker::new(&frecency_path, opts.use_unsafe_no_lock) {
|
||||
Ok(tracker) => {
|
||||
let mut guard = match shared_frecency.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!("Failed to acquire frecency lock: {}", e));
|
||||
}
|
||||
};
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize query tracker if path is provided
|
||||
if let Some(history_path) = opts.history_db_path {
|
||||
if let Some(parent) = PathBuf::from(&history_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match QueryTracker::new(&history_path, opts.use_unsafe_no_lock) {
|
||||
Ok(tracker) => {
|
||||
let mut guard = match query_tracker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!(
|
||||
"Failed to acquire query tracker lock: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
};
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init query tracker db: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize file picker (writes directly into shared_picker)
|
||||
if let Err(e) = FilePicker::new_with_shared_state(
|
||||
opts.base_path,
|
||||
opts.warmup_mmap_cache,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
) {
|
||||
return FffResult::err(&format!("Failed to init file picker: {}", e));
|
||||
}
|
||||
|
||||
let instance = Box::new(FffInstance {
|
||||
picker: shared_picker,
|
||||
frecency: shared_frecency,
|
||||
query_tracker,
|
||||
});
|
||||
|
||||
// Return the instance pointer inside the data field of FffResult.
|
||||
// We encode the pointer as a hex string so consumers can store it as an
|
||||
// opaque token. The actual pointer is also returned as the `data` pointer
|
||||
// for FFI consumers that can directly use it.
|
||||
let fff_handle = Box::into_raw(instance) as *mut c_void;
|
||||
FffResult::ok_handle(fff_handle)
|
||||
}
|
||||
|
||||
/// Destroy a file finder instance and free all its resources.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid pointer returned by `fff_create`, or null (no-op).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) {
|
||||
if fff_handle.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let instance = unsafe { Box::from_raw(fff_handle as *mut FffInstance) };
|
||||
|
||||
if let Ok(mut guard) = instance.picker.write()
|
||||
&& let Some(mut picker) = guard.take()
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = instance.frecency.write() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Ok(mut guard) = instance.query_tracker.write() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform fuzzy search on indexed files.
|
||||
///
|
||||
/// # Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `query` and `opts_json` must be valid null-terminated UTF-8 strings.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
opts_json: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
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 picker_guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized. Call fff_create 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 qt_guard = match inst.query_tracker.read() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::err("Failed to acquire query tracker lock"),
|
||||
};
|
||||
|
||||
qt_guard.as_ref().and_then(|tracker| {
|
||||
tracker
|
||||
.get_last_query_entry(query_str, base_path, min_combo_count)
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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.
|
||||
///
|
||||
/// # Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `query` and `opts_json` must be valid null-terminated UTF-8 strings.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_live_grep(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
opts_json: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
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 picker_guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized. Call fff_create 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(0),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a rescan of the file index.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_scan_files(fff_handle: *mut c_void) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let mut guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match guard.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
match picker.trigger_rescan(&inst.frecency) {
|
||||
Ok(_) => FffResult::ok_empty(),
|
||||
Err(e) => FffResult::err(&format!("Failed to trigger rescan: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a scan is currently in progress.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_is_scanning(fff_handle: *mut c_void) -> bool {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
inst.picker
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|p| p.is_scan_active()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get scan progress information.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_get_scan_progress(fff_handle: *mut c_void) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match guard.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.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_wait_for_scan(
|
||||
fff_handle: *mut c_void,
|
||||
timeout_ms: u64,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
// Clone the scanning flag so we can drop the picker lock before polling.
|
||||
// Otherwise the read lock blocks the scan thread from writing results.
|
||||
let scan_signal = {
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
picker.scan_signal()
|
||||
// guard is dropped here, releasing the read lock
|
||||
};
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let start = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
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
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `new_path` must be a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_restart_index(
|
||||
fff_handle: *mut c_void,
|
||||
new_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
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 guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
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) = guard.take() {
|
||||
let warmup = picker.warmup_mmap_cache();
|
||||
picker.stop_background_monitor();
|
||||
warmup
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Drop the write lock before calling new_with_shared_state,
|
||||
// which will acquire its own write lock to place the picker.
|
||||
drop(guard);
|
||||
|
||||
// Create new picker backed by the same shared state
|
||||
match FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
warmup,
|
||||
Arc::clone(&inst.picker),
|
||||
Arc::clone(&inst.frecency),
|
||||
) {
|
||||
Ok(()) => FffResult::ok_empty(),
|
||||
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Track file access for frecency scoring.
|
||||
///
|
||||
/// # Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `file_path` must be a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_track_access(
|
||||
fff_handle: *mut c_void,
|
||||
file_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
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 inst.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"),
|
||||
};
|
||||
|
||||
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 picker_guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match picker_guard.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
let frecency_guard = match inst.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")
|
||||
}
|
||||
|
||||
/// Refresh git status cache.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_refresh_git_status(fff_handle: *mut c_void) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
match FilePicker::refresh_git_status(&inst.picker, &inst.frecency) {
|
||||
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
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `query` and `file_path` must be valid null-terminated UTF-8 strings.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_track_query(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
file_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
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 guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
match guard.as_ref() {
|
||||
Some(p) => p.base_path().to_path_buf(),
|
||||
None => return FffResult::ok_data("false"),
|
||||
}
|
||||
};
|
||||
|
||||
let mut qt_guard = match inst.query_tracker.write() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
if let Some(ref mut tracker) = *qt_guard
|
||||
&& 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).
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_get_historical_query(
|
||||
fff_handle: *mut c_void,
|
||||
offset: u64,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let project_path = {
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return FffResult::ok_data("null"),
|
||||
};
|
||||
match guard.as_ref() {
|
||||
Some(p) => p.base_path().to_path_buf(),
|
||||
None => return FffResult::ok_data("null"),
|
||||
}
|
||||
};
|
||||
|
||||
let qt_guard = match inst.query_tracker.read() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::ok_data("null"),
|
||||
};
|
||||
|
||||
let tracker = match qt_guard.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
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`, or null for
|
||||
/// a limited health check (version + git only).
|
||||
/// * `test_path` can be null or a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_health_check(
|
||||
fff_handle: *mut c_void,
|
||||
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));
|
||||
|
||||
// Resolve the instance once (None when handle is null).
|
||||
let inst: Option<&FffInstance> = if fff_handle.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { &*(fff_handle as *const FffInstance) })
|
||||
};
|
||||
|
||||
// File picker info
|
||||
let mut picker_info = serde_json::Map::new();
|
||||
if let Some(inst) = inst {
|
||||
match inst.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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
health.insert(
|
||||
"file_picker".to_string(),
|
||||
serde_json::Value::Object(picker_info),
|
||||
);
|
||||
|
||||
// Frecency info
|
||||
let mut frecency_info = serde_json::Map::new();
|
||||
if let Some(inst) = inst {
|
||||
match inst.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));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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();
|
||||
if let Some(inst) = inst {
|
||||
match inst.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));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
[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 = []
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
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, optional = true }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
@@ -0,0 +1,46 @@
|
||||
fn main() {
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
|
||||
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
}
|
||||
} else if std::env::var("CI").is_ok() {
|
||||
// CI must always build with zlob for production-quality binaries.
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"CI detected but Zig is not installed. \
|
||||
Please install Zig and build with `--features zlob`."
|
||||
);
|
||||
}
|
||||
panic!(
|
||||
"CI detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob`."
|
||||
);
|
||||
} else {
|
||||
// Hint: if Zig is available but the zlob feature wasn't enabled,
|
||||
// let the developer know they can get faster glob matching.
|
||||
if zig_available() {
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the system for a working Zig installation.
|
||||
fn zig_available() -> bool {
|
||||
std::process::Command::new("zig")
|
||||
.arg("version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
|
||||
|
||||
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>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing background watcher for path: {}",
|
||||
base_path.display()
|
||||
);
|
||||
|
||||
let debouncer =
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency)?;
|
||||
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>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<Debouncer, Error> {
|
||||
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
|
||||
// files that could be git ignored, we have to property differentiate those and if
|
||||
// the file was edited through a
|
||||
let config = Config::default().with_follow_symlinks(false);
|
||||
|
||||
let git_workdir_for_handler = git_workdir.clone();
|
||||
let mut debouncer = new_debouncer_opt(
|
||||
DEBOUNCE_TIMEOUT,
|
||||
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
|
||||
{
|
||||
move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
handle_debounced_events(
|
||||
events,
|
||||
&git_workdir_for_handler,
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
);
|
||||
}
|
||||
Err(errors) => {
|
||||
error!("File watcher errors: {:?}", errors);
|
||||
}
|
||||
}
|
||||
},
|
||||
// There is an issue with recommended cache implementation on macos
|
||||
// it keeps track of all the files added to the watcher which is not a problem
|
||||
// for us because any rename to the file will anyway require the removing from the
|
||||
// ordedred index and adding it back with the new name
|
||||
NoCache::new(),
|
||||
config,
|
||||
)?;
|
||||
|
||||
// Watch only non-ignored directories to avoid flooding the OS event buffer.
|
||||
// On macOS, FSEvents has a fixed-size kernel buffer — watching huge gitignored
|
||||
// directories like `target/` in rust causes buffer overflow, which drops real source file
|
||||
// events. Instead we watch the root non-recursively (for top-level file changes
|
||||
// and new directory detection) and each non-ignored subdirectory recursively.
|
||||
let watch_dirs = collect_non_ignored_dirs(&base_path);
|
||||
|
||||
if watch_dirs.len() > MAX_SELECTIVE_WATCH_DIRS {
|
||||
tracing::warn!(
|
||||
"Too many non-ignored directories ({}/{}) can't efficiently watch them",
|
||||
watch_dirs.len(),
|
||||
MAX_SELECTIVE_WATCH_DIRS
|
||||
);
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
} else {
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
|
||||
|
||||
for dir in &watch_dirs {
|
||||
match debouncer.watch(dir.as_path(), RecursiveMode::Recursive) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// Non-fatal: directory may have been removed between discovery and watch
|
||||
warn!("Failed to watch directory {}: {}", dir.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In selective mode the .git directory is excluded from the non-ignored
|
||||
// dirs, but we still need to observe changes that affect git status
|
||||
// (staging, unstaging, committing, branch switches, merges, etc.).
|
||||
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
|
||||
}
|
||||
|
||||
info!(
|
||||
"File watcher initialized for {} directories under {}",
|
||||
watch_dirs.len(),
|
||||
base_path.display()
|
||||
);
|
||||
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
pub fn stop(&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, shared_picker, shared_frecency), level = Level::DEBUG)]
|
||||
fn handle_debounced_events(
|
||||
events: Vec<DebouncedEvent>,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) {
|
||||
// this will be called very often, we have to minimiy the lock time for file picker
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
let mut need_full_rescan = false;
|
||||
let mut need_full_git_rescan = false;
|
||||
let mut paths_to_remove = Vec::new();
|
||||
let mut paths_to_add_or_modify = Vec::new();
|
||||
let mut affected_paths_count = 0usize;
|
||||
|
||||
for debounced_event in &events {
|
||||
// It is very important to not react to the access errors because we inevitably
|
||||
// gonna trigger the sync by our own preview or other unnecessary noise
|
||||
if matches!(
|
||||
debounced_event.event.kind,
|
||||
EventKind::Access(
|
||||
AccessKind::Read
|
||||
| AccessKind::Open(_)
|
||||
| AccessKind::Close(AccessMode::Read | AccessMode::Execute)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// When macOS FSEvents (or other backends) overflow their event buffer, the kernel
|
||||
// drops individual events and emits a Rescan flag telling us to re-scan the subtree.
|
||||
// Without handling this, modified source files can be silently missed.
|
||||
if debounced_event.event.need_rescan() {
|
||||
warn!(
|
||||
"Received rescan event for paths {:?}, triggering full rescan",
|
||||
debounced_event.event.paths
|
||||
);
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
|
||||
for path in &debounced_event.event.paths {
|
||||
if is_ignore_definition_path(path) {
|
||||
info!(
|
||||
"Detected change in ignore definition file: {}",
|
||||
path.display()
|
||||
);
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if is_dotgit_change_affecting_status(path, &repo) {
|
||||
need_full_git_rescan = true;
|
||||
}
|
||||
|
||||
if is_git_file(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use a combination of event kind and filesystem state to decide
|
||||
// whether a path is an addition/modification or a removal.
|
||||
//
|
||||
// We cannot rely on `path.exists()` alone because:
|
||||
// - A freshly created file might not be visible yet (race).
|
||||
// - macOS FSEvents uses Modify(Name(Any)) for both rename-in
|
||||
// and rename-out, so we must stat the path to disambiguate.
|
||||
//
|
||||
// We cannot rely on event kind alone because:
|
||||
// - Remove events are not always emitted (macOS often sends
|
||||
// Modify(Name(Any)) instead of Remove).
|
||||
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
|
||||
|
||||
if is_removal || !path.exists() {
|
||||
paths_to_remove.push(path.as_path());
|
||||
} else {
|
||||
// For additions/modifications, still filter gitignored files.
|
||||
if should_include_file(path, &repo) {
|
||||
paths_to_add_or_modify.push(path.as_path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
affected_paths_count += debounced_event.event.paths.len();
|
||||
if affected_paths_count > MAX_PATHS_THRESHOLD {
|
||||
warn!(
|
||||
"Too many affected paths ({}) in a single batch, triggering full rescan",
|
||||
affected_paths_count
|
||||
);
|
||||
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if need_full_rescan {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if need_full_rescan {
|
||||
info!(?affected_paths_count, "Triggering full rescan");
|
||||
trigger_full_rescan(shared_picker, shared_frecency);
|
||||
return;
|
||||
}
|
||||
|
||||
// It's important to get the allocated sort
|
||||
sort_with_buffer(paths_to_add_or_modify.as_mut_slice(), |a, b| {
|
||||
a.as_os_str().cmp(b.as_os_str())
|
||||
});
|
||||
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
|
||||
|
||||
info!(
|
||||
"Event processing summary: {} to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len()
|
||||
);
|
||||
|
||||
// Apply file index updates (add/remove) unconditionally — these must
|
||||
// happen even when there is no git repository.
|
||||
let files_to_update_git_status =
|
||||
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
|
||||
debug!(
|
||||
"Applying file index changes: {} to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
);
|
||||
|
||||
let apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
|
||||
for path in &paths_to_remove {
|
||||
let removed = picker.remove_file_by_path(path);
|
||||
debug!("remove_file_by_path({:?}) -> {}", path, removed);
|
||||
}
|
||||
|
||||
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in &paths_to_add_or_modify {
|
||||
let result = picker.on_create_or_modify(path);
|
||||
match result {
|
||||
Some(file) => {
|
||||
debug!(
|
||||
"on_create_or_modify({:?}) -> Some({})",
|
||||
path,
|
||||
file.path.display()
|
||||
);
|
||||
files_to_update.push(file.path.clone());
|
||||
}
|
||||
None => {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"apply_changes complete: {} files to update git status",
|
||||
files_to_update.len()
|
||||
);
|
||||
files_to_update
|
||||
};
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock");
|
||||
return;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized");
|
||||
return;
|
||||
};
|
||||
apply_changes(picker)
|
||||
} else {
|
||||
debug!("No file index changes to apply");
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Git status updates require a repository.
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
debug!("No git repo available, skipping git status updates");
|
||||
return;
|
||||
};
|
||||
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
|
||||
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
|
||||
if let Err(e) = result {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !files_to_update_git_status.is_empty() {
|
||||
info!(
|
||||
"Fetching git status for {} files",
|
||||
files_to_update_git_status.len()
|
||||
);
|
||||
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to query git status");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
if let Err(e) = picker.update_git_statuses(status, shared_frecency) {
|
||||
error!("Failed to update git statuses: {:?}", e);
|
||||
} else {
|
||||
info!("Successfully updated git statuses in picker");
|
||||
}
|
||||
} else {
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
|
||||
info!("Triggering full filesystem rescan");
|
||||
|
||||
// Note: no need to clear mmaps — they are backed by the kernel page cache
|
||||
// and automatically reflect file changes. Old FileItems (and their mmaps)
|
||||
// are dropped when the picker rebuilds its file list.
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock for full rescan");
|
||||
return;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized, cannot trigger rescan");
|
||||
return;
|
||||
};
|
||||
if let Err(e) = picker.trigger_rescan(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
} else {
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
}
|
||||
}
|
||||
|
||||
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
// Directories are not indexed — only regular files (and symlinks to files).
|
||||
if path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there is a git repo, respect its ignore rules.
|
||||
// If there is no repo (or the check fails), include the file.
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) != Ok(true),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_git_file(path: &Path) -> bool {
|
||||
path.components()
|
||||
.any(|component| component.as_os_str() == ".git")
|
||||
}
|
||||
|
||||
pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let git_dir = repo.path();
|
||||
|
||||
if let Ok(rel) = changed.strip_prefix(git_dir) {
|
||||
if rel.starts_with("objects") || rel.starts_with("logs") || rel.starts_with("hooks") {
|
||||
return false;
|
||||
}
|
||||
if rel == Path::new("index") || rel == Path::new("index.lock") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("HEAD") {
|
||||
return true;
|
||||
}
|
||||
if rel.starts_with("refs") || rel == Path::new("packed-refs") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("info/exclude") || rel == Path::new("info/sparse-checkout") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(fname) = rel.file_name().and_then(|f| f.to_str())
|
||||
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_ignore_definition_path(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.file_name().and_then(|f| f.to_str()),
|
||||
Some(".ignore") | Some(".gitignore")
|
||||
)
|
||||
}
|
||||
|
||||
fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBuf>) {
|
||||
let Some(workdir) = git_workdir else {
|
||||
return;
|
||||
};
|
||||
|
||||
let git_dir = workdir.join(".git");
|
||||
if !git_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch .git/ non-recursively to catch top-level files:
|
||||
// index, index.lock, HEAD, packed-refs, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD
|
||||
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
|
||||
warn!("Failed to watch .git directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch refs/ recursively to catch branch/tag changes
|
||||
let refs_dir = git_dir.join("refs");
|
||||
if refs_dir.is_dir()
|
||||
&& let Err(e) = debouncer.watch(&refs_dir, RecursiveMode::Recursive)
|
||||
{
|
||||
warn!("Failed to watch .git/refs: {}", e);
|
||||
}
|
||||
|
||||
// Watch info/ non-recursively for exclude and sparse-checkout
|
||||
let info_dir = git_dir.join("info");
|
||||
if info_dir.is_dir()
|
||||
&& let Err(e) = debouncer.watch(&info_dir, RecursiveMode::NonRecursive)
|
||||
{
|
||||
warn!("Failed to watch .git/info: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
|
||||
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
|
||||
/// selective file watching — only non-ignored directories get a recursive watcher,
|
||||
/// preventing gitignored directories like `target/` from flooding the OS event buffer.
|
||||
fn collect_non_ignored_dirs(base_path: &Path) -> Vec<PathBuf> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let walker = WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.max_depth(Some(1))
|
||||
.build();
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
for entry in walker {
|
||||
let Ok(entry) = entry else { continue };
|
||||
let path = entry.path();
|
||||
|
||||
// Skip the root directory itself
|
||||
if path == base_path {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() && !is_git_file(path) {
|
||||
dirs.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Constraint filtering engine for fff.
|
||||
//!
|
||||
//! This module provides the core constraint application logic that filters items
|
||||
//! based on parsed query constraints (extensions, path segments, globs, git status, etc.).
|
||||
//!
|
||||
//! The filtering is generic over the [`Constrainable`] trait, allowing reuse across
|
||||
//! different search modes (file picker, live grep, etc.).
|
||||
|
||||
use ahash::AHashSet;
|
||||
use fff_query_parser::{Constraint, GitStatusFilter};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
|
||||
/// Minimum item count before switching to parallel iteration with rayon.
|
||||
/// Below this threshold, the overhead of thread pool dispatch outweighs the benefit.
|
||||
const PAR_THRESHOLD: usize = 10_000;
|
||||
|
||||
/// Trait for items that can be filtered by constraints.
|
||||
/// Implement this for any searchable item type (files, grep results, etc.).
|
||||
pub trait Constrainable {
|
||||
/// The file's relative path (e.g. "src/main.rs")
|
||||
fn relative_path(&self) -> &str;
|
||||
|
||||
/// The file's lowercased relative path for case-insensitive matching
|
||||
fn relative_path_lower(&self) -> &str;
|
||||
|
||||
/// The file name component (e.g. "main.rs")
|
||||
fn file_name(&self) -> &str;
|
||||
|
||||
/// The git status of this item, if available
|
||||
fn git_status(&self) -> Option<git2::Status>;
|
||||
}
|
||||
|
||||
/// Check if 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) => {
|
||||
let indices = match_glob_pattern(pattern, paths);
|
||||
results.push((is_negated, indices));
|
||||
}
|
||||
Constraint::Not(inner) => {
|
||||
collect_glob_indices(inner, paths, results, !is_negated);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a glob pattern against a list of paths, returning the set of matching indices.
|
||||
///
|
||||
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
|
||||
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
|
||||
#[cfg(feature = "zlob")]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
|
||||
else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
|
||||
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
|
||||
|
||||
if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(glob) = globset::Glob::new(pattern) else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
let matcher = glob.compile_matcher();
|
||||
|
||||
if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_file_has_extension() {
|
||||
assert!(file_has_extension("file.rs", "rs"));
|
||||
assert!(file_has_extension("file.RS", "rs")); // case-insensitive
|
||||
assert!(file_has_extension("file.test.rs", "rs"));
|
||||
assert!(file_has_extension("a.rs", "rs"));
|
||||
|
||||
assert!(!file_has_extension("file.tsx", "rs"));
|
||||
assert!(!file_has_extension("rs", "rs")); // too short
|
||||
assert!(!file_has_extension(".rs", "rs")); // just extension
|
||||
assert!(!file_has_extension("file.rsx", "rs")); // different extension
|
||||
assert!(!file_has_extension("filers", "rs")); // no dot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_contains_segment() {
|
||||
// Segment at start
|
||||
assert!(path_contains_segment("src/lib.rs", "src"));
|
||||
assert!(path_contains_segment("SRC/lib.rs", "src")); // case-insensitive
|
||||
|
||||
// Segment in middle
|
||||
assert!(path_contains_segment("app/src/lib.rs", "src"));
|
||||
assert!(path_contains_segment("app/SRC/lib.rs", "src"));
|
||||
|
||||
// Multiple levels
|
||||
assert!(path_contains_segment("core/workflow/src/main.rs", "src"));
|
||||
assert!(path_contains_segment(
|
||||
"core/workflow/src/main.rs",
|
||||
"workflow"
|
||||
));
|
||||
assert!(path_contains_segment("core/workflow/src/main.rs", "core"));
|
||||
|
||||
// Should not match partial segments
|
||||
assert!(!path_contains_segment("source/lib.rs", "src"));
|
||||
assert!(!path_contains_segment("mysrc/lib.rs", "src"));
|
||||
|
||||
// Should not match filename
|
||||
assert!(!path_contains_segment("lib/src", "src"));
|
||||
|
||||
// Edge cases
|
||||
assert!(!path_contains_segment("", "src"));
|
||||
assert!(!path_contains_segment("src", "src")); // no trailing slash
|
||||
}
|
||||
}
|
||||
@@ -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,22 +1,22 @@
|
||||
use std::path::StripPrefixError;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
#[error("Thread panicked")]
|
||||
ThreadPanic,
|
||||
#[error("Invalid path {0}")]
|
||||
InvalidPath(std::path::PathBuf),
|
||||
#[error("File picker not initialized")]
|
||||
FilePickerMissing,
|
||||
#[error("Failed to acquire lock for frecency")]
|
||||
AcquireFrecencyLock,
|
||||
|
||||
#[error("Failed to acquire lock for items by provider")]
|
||||
AcquireItemLock,
|
||||
|
||||
#[error("Attempted to use frecency before initialization")]
|
||||
UseFrecencyBeforeInit,
|
||||
|
||||
#[error(
|
||||
"Attempted to fuzzy match for provider {provider_id} before setting the provider's items"
|
||||
)]
|
||||
FuzzyBeforeSetItems { provider_id: String },
|
||||
|
||||
#[error("Failed to create frecency database directory: {0}")]
|
||||
CreateDir(#[source] std::io::Error),
|
||||
#[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}")]
|
||||
EnvOpen(#[source] heed::Error),
|
||||
#[error("Failed to create frecency database: {0}")]
|
||||
@@ -35,16 +35,14 @@ pub enum Error {
|
||||
DbWrite(#[source] heed::Error),
|
||||
#[error("Failed to commit write transaction to frecency database: {0}")]
|
||||
DbCommit(#[source] heed::Error),
|
||||
#[error("Failed to start file system watcher: {0}")]
|
||||
FileSystemWatch(#[from] notify::Error),
|
||||
|
||||
#[error("Invalid file path: {0}")]
|
||||
InvalidPath(String),
|
||||
#[error("Expected a path to be child of another path: {0}")]
|
||||
StripPrefixError(#[from] StripPrefixError),
|
||||
|
||||
#[error("Failed to scan directory: {0}")]
|
||||
DirectoryScan(String),
|
||||
#[error("libgit2 error occurred: {0}")]
|
||||
Git(#[from] git2::Error),
|
||||
}
|
||||
|
||||
impl From<Error> for mlua::Error {
|
||||
fn from(value: Error) -> Self {
|
||||
mlua::Error::RuntimeError(value.to_string())
|
||||
}
|
||||
}
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,901 @@
|
||||
use crate::background_watcher::BackgroundWatcher;
|
||||
use crate::error::Error;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use crate::score::match_and_score_files;
|
||||
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use fff_query_parser::FFFQuery;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use rayon::prelude::*;
|
||||
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::{Level, debug, error, info, warn};
|
||||
|
||||
/// 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 {
|
||||
/// Files sorted by path for binary search
|
||||
files: Vec<FileItem>,
|
||||
pub git_workdir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl FileSync {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Vec::new(),
|
||||
git_workdir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all files (read-only). Files are sorted by path.
|
||||
#[inline]
|
||||
fn files(&self) -> &[FileItem] {
|
||||
&self.files
|
||||
}
|
||||
|
||||
fn get_file(&self, index: usize) -> Option<&FileItem> {
|
||||
self.files.get(index)
|
||||
}
|
||||
|
||||
/// Get mutable file at index
|
||||
#[inline]
|
||||
fn get_file_mut(&mut self, index: usize) -> Option<&mut FileItem> {
|
||||
self.files.get_mut(index)
|
||||
}
|
||||
|
||||
/// Find file index by path using binary search - O(log n)
|
||||
#[inline]
|
||||
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
|
||||
self.files.binary_search_by(|f| f.path.as_path().cmp(path))
|
||||
}
|
||||
|
||||
/// Get file count
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// Insert a file at position. Simple - no HashMap to maintain!
|
||||
fn insert_file(&mut self, position: usize, file: FileItem) {
|
||||
self.files.insert(position, file);
|
||||
}
|
||||
|
||||
/// Remove file at index. Simple - no HashMap to maintain!
|
||||
fn remove_file(&mut self, index: usize) {
|
||||
if index < self.files.len() {
|
||||
self.files.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove files matching predicate.
|
||||
/// Returns number of files removed.
|
||||
fn retain_files<F>(&mut self, predicate: F) -> usize
|
||||
where
|
||||
F: FnMut(&FileItem) -> bool,
|
||||
{
|
||||
let initial_len = self.files.len();
|
||||
self.files.retain(predicate);
|
||||
initial_len - self.files.len()
|
||||
}
|
||||
|
||||
/// Insert a file in sorted order (by path).
|
||||
/// Returns true if inserted, false if file already exists.
|
||||
fn insert_file_sorted(&mut self, file: FileItem) -> bool {
|
||||
match self.find_file_index(&file.path) {
|
||||
Ok(_) => false, // File already exists
|
||||
Err(position) => {
|
||||
self.insert_file(position, file);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileItem {
|
||||
pub fn new(path: PathBuf, base_path: &Path, git_status: Option<Status>) -> Self {
|
||||
let relative_path = pathdiff::diff_paths(&path, base_path)
|
||||
.unwrap_or_else(|| path.clone())
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let (size, modified) = match std::fs::metadata(&path) {
|
||||
Ok(metadata) => {
|
||||
let size = metadata.len();
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_secs());
|
||||
|
||||
(size, modified)
|
||||
}
|
||||
Err(_) => (0, 0),
|
||||
};
|
||||
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
Self::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
name,
|
||||
size,
|
||||
modified,
|
||||
git_status,
|
||||
is_binary,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> {
|
||||
self.access_frecency_score = tracker.get_access_score(&self.path);
|
||||
self.modification_frecency_score =
|
||||
tracker.get_modification_score(self.modified, self.git_status);
|
||||
self.total_frecency_score = self.access_frecency_score + self.modification_frecency_score;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilePicker {
|
||||
base_path: PathBuf,
|
||||
sync_data: FileSync,
|
||||
is_scanning: Arc<AtomicBool>,
|
||||
scanned_files_count: Arc<AtomicUsize>,
|
||||
background_watcher: Option<BackgroundWatcher>,
|
||||
warmup_mmap_cache: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FilePicker")
|
||||
.field("base_path", &self.base_path)
|
||||
.field("sync_data", &self.sync_data)
|
||||
.field("is_scanning", &self.is_scanning.load(Ordering::Relaxed))
|
||||
.field(
|
||||
"scanned_files_count",
|
||||
&self.scanned_files_count.load(Ordering::Relaxed),
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Get all indexed files sorted by path.
|
||||
/// Note: Files are stored sorted by PATH for efficient insert/remove.
|
||||
/// For frecency-sorted results, use search() which sorts matched results.
|
||||
pub fn get_files(&self) -> &[FileItem] {
|
||||
self.sync_data.files()
|
||||
}
|
||||
|
||||
/// Create a new FilePicker and place it into the provided shared handle.
|
||||
///
|
||||
/// The background scan thread and file-system watcher write into the
|
||||
/// provided `SharedPicker` and read frecency data from the provided
|
||||
/// `SharedFrecency`.
|
||||
///
|
||||
/// Multiple independent instances can coexist in the same process.
|
||||
pub fn new_with_shared_state(
|
||||
base_path: String,
|
||||
warmup_mmap_cache: bool,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<(), 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);
|
||||
return Err(Error::InvalidPath(path));
|
||||
}
|
||||
|
||||
// Initialize scan_signal to `true` so that any `wait_for_scan` call
|
||||
// that races with the background thread sees "scanning in progress"
|
||||
// rather than a stale `false` (the thread hasn't started yet).
|
||||
let scan_signal = Arc::new(AtomicBool::new(true));
|
||||
let synced_files_count = Arc::new(AtomicUsize::new(0));
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let picker = FilePicker {
|
||||
base_path: path.clone(),
|
||||
sync_data: FileSync::new(),
|
||||
is_scanning: Arc::clone(&scan_signal),
|
||||
scanned_files_count: Arc::clone(&synced_files_count),
|
||||
background_watcher: None,
|
||||
warmup_mmap_cache,
|
||||
cancelled: Arc::clone(&cancelled),
|
||||
};
|
||||
|
||||
// Place the picker into the shared handle before spawning the
|
||||
// background thread so the thread can find it immediately.
|
||||
{
|
||||
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
*guard = Some(picker);
|
||||
}
|
||||
|
||||
spawn_scan_and_watcher(
|
||||
path.clone(),
|
||||
Arc::clone(&scan_signal),
|
||||
Arc::clone(&synced_files_count),
|
||||
warmup_mmap_cache,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
cancelled,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
parsed: Option<FFFQuery<'a>>,
|
||||
options: FuzzySearchOptions<'a>,
|
||||
) -> SearchResult<'a> {
|
||||
let max_threads = options.max_threads.max(1);
|
||||
debug!(
|
||||
?query,
|
||||
parsed_is_some = parsed.is_some(),
|
||||
pagination = ?options.pagination,
|
||||
?max_threads,
|
||||
current_file = ?options.current_file,
|
||||
"Fuzzy search",
|
||||
);
|
||||
|
||||
let total_files = files.len();
|
||||
|
||||
// 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 = (effective_query.len() as u16 / 4).clamp(2, 6);
|
||||
|
||||
let context = ScoringContext {
|
||||
raw_query: query,
|
||||
parsed_query: parsed,
|
||||
project_path: options.project_path,
|
||||
max_typos,
|
||||
max_threads,
|
||||
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(),
|
||||
total_matched,
|
||||
returned_count = items.len(),
|
||||
pagination = ?options.pagination,
|
||||
"Fuzzy search completed",
|
||||
);
|
||||
|
||||
SearchResult {
|
||||
items,
|
||||
scores,
|
||||
total_matched,
|
||||
total_files,
|
||||
location,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_scan_progress(&self) -> ScanProgress {
|
||||
let scanned_count = self.scanned_files_count.load(Ordering::Relaxed);
|
||||
let is_scanning = self.is_scanning.load(Ordering::Relaxed);
|
||||
ScanProgress {
|
||||
scanned_files_count: scanned_count,
|
||||
is_scanning,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update git statuses for files, using the provided shared frecency tracker.
|
||||
pub fn update_git_statuses(
|
||||
&mut self,
|
||||
status_cache: GitStatusCache,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
debug!(
|
||||
statuses_count = status_cache.statuses_len(),
|
||||
"Updating git status",
|
||||
);
|
||||
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
status_cache
|
||||
.into_iter()
|
||||
.try_for_each(|(path, status)| -> Result<(), Error> {
|
||||
if let Some(file) = self.get_mut_file_by_path(&path) {
|
||||
file.git_status = Some(status);
|
||||
if let Some(ref f) = *frecency {
|
||||
file.update_frecency_scores(f)?;
|
||||
}
|
||||
} else {
|
||||
error!(?path, "Couldn't update the git status for path");
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refreshes git statuses using the provided shared picker and frecency handles.
|
||||
pub fn refresh_git_status(
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<usize, Error> {
|
||||
let git_status = {
|
||||
let guard = shared_picker.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Refreshing git statuses for picker: {:?}",
|
||||
picker.git_root()
|
||||
);
|
||||
|
||||
GitStatusCache::read_git_status(
|
||||
picker.git_root(),
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true),
|
||||
)
|
||||
};
|
||||
|
||||
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
|
||||
|
||||
let statuses_count = if let Some(git_status) = git_status {
|
||||
let count = git_status.statuses_len();
|
||||
picker.update_git_statuses(git_status, shared_frecency)?;
|
||||
count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(statuses_count)
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(
|
||||
&mut self,
|
||||
file_path: impl AsRef<Path>,
|
||||
frecency_tracker: &FrecencyTracker,
|
||||
) -> Result<(), Error> {
|
||||
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
|
||||
&& let Some(file) = self.sync_data.get_file_mut(index)
|
||||
{
|
||||
file.update_frecency_scores(frecency_tracker)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_file_by_path(&self, path: impl AsRef<Path>) -> Option<&FileItem> {
|
||||
self.sync_data
|
||||
.find_file_index(path.as_ref())
|
||||
.ok()
|
||||
.and_then(|index| self.sync_data.files().get(index))
|
||||
}
|
||||
|
||||
pub fn get_mut_file_by_path(&mut self, path: impl AsRef<Path>) -> Option<&mut FileItem> {
|
||||
self.sync_data
|
||||
.find_file_index(path.as_ref())
|
||||
.ok()
|
||||
.and_then(|index| self.sync_data.get_file_mut(index))
|
||||
}
|
||||
|
||||
/// Add a file to the picker's files in sorted order (used by background watcher)
|
||||
pub fn add_file_sorted(&mut self, file: FileItem) -> Option<&FileItem> {
|
||||
let path = file.path.clone();
|
||||
|
||||
if self.sync_data.insert_file_sorted(file) {
|
||||
// File was inserted, look it up
|
||||
self.sync_data
|
||||
.find_file_index(&path)
|
||||
.ok()
|
||||
.and_then(|idx| self.sync_data.get_file_mut(idx))
|
||||
.map(|file_mut| &*file_mut) // Convert &mut to &
|
||||
} else {
|
||||
// File already exists
|
||||
warn!(
|
||||
"Trying to insert a file that already exists: {}",
|
||||
path.display()
|
||||
);
|
||||
self.sync_data
|
||||
.find_file_index(&path)
|
||||
.ok()
|
||||
.and_then(|idx| self.sync_data.get_file_mut(idx))
|
||||
.map(|file_mut| &*file_mut) // Convert &mut to &
|
||||
}
|
||||
}
|
||||
|
||||
#[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) => {
|
||||
debug!(
|
||||
"on_create_or_modify: file EXISTS at index {}, updating metadata",
|
||||
pos
|
||||
);
|
||||
// File exists - update its metadata (doesn't change indices, safe)
|
||||
let file = self.sync_data.get_file_mut(pos)?;
|
||||
|
||||
let modified = match std::fs::metadata(path) {
|
||||
Ok(metadata) => metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()),
|
||||
Err(e) => {
|
||||
error!("Failed to get metadata for {}: {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(modified) = modified {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Some(&*file) // Convert &mut to &
|
||||
}
|
||||
Err(pos) => {
|
||||
debug!(
|
||||
"on_create_or_modify: file NEW, inserting at index {} (total files: {})",
|
||||
pos,
|
||||
self.sync_data.files().len()
|
||||
);
|
||||
|
||||
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
|
||||
let path_buf = file_item.path.clone();
|
||||
|
||||
self.sync_data.insert_file(pos, file_item);
|
||||
let result = self.sync_data.get_file(pos);
|
||||
|
||||
if result.is_none() {
|
||||
error!(
|
||||
"on_create_or_modify: FAILED to find file after insert! path={:?}",
|
||||
path_buf
|
||||
);
|
||||
} else {
|
||||
debug!("on_create_or_modify: successfully inserted and found file");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
|
||||
let path = path.as_ref();
|
||||
match self.sync_data.find_file_index(path) {
|
||||
Ok(index) => {
|
||||
self.sync_data.remove_file(index);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO make this O(n)
|
||||
pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
|
||||
let dir_path = dir.as_ref();
|
||||
// Use the safe retain_files method which maintains both indices
|
||||
self.sync_data
|
||||
.retain_files(|file| !file.path.starts_with(dir_path))
|
||||
}
|
||||
|
||||
/// We use this to prevent any substantial background threads from acquiring the locks
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(&mut self) {
|
||||
if let Some(watcher) = self.background_watcher.take() {
|
||||
watcher.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_rescan(&mut self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
|
||||
if self.is_scanning.load(Ordering::Relaxed) {
|
||||
debug!("Scan already in progress, skipping trigger_rescan");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.is_scanning.store(true, Ordering::Relaxed);
|
||||
self.scanned_files_count.store(0, Ordering::Relaxed);
|
||||
|
||||
let scan_result =
|
||||
scan_filesystem(&self.base_path, &self.scanned_files_count, shared_frecency);
|
||||
match scan_result {
|
||||
Ok(sync) => {
|
||||
info!(
|
||||
"Filesystem scan completed: found {} files",
|
||||
sync.files.len()
|
||||
);
|
||||
|
||||
self.sync_data = sync;
|
||||
|
||||
if self.warmup_mmap_cache {
|
||||
// Warmup in background to avoid blocking
|
||||
let files = self.sync_data.files().to_vec(); // Clone all files
|
||||
std::thread::spawn(move || {
|
||||
warmup_mmaps(&files);
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => error!(?error, "Failed to scan file system"),
|
||||
}
|
||||
|
||||
self.is_scanning.store(false, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_scan_active(&self) -> bool {
|
||||
self.is_scanning.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Return a clone of the scanning flag so callers can poll it without
|
||||
/// holding a lock on the picker.
|
||||
pub fn scan_signal(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.is_scanning)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanProgress {
|
||||
pub scanned_files_count: usize,
|
||||
pub is_scanning: bool,
|
||||
}
|
||||
|
||||
fn spawn_scan_and_watcher(
|
||||
base_path: PathBuf,
|
||||
scan_signal: Arc<AtomicBool>,
|
||||
synced_files_count: Arc<AtomicUsize>,
|
||||
warmup_mmap_cache: bool,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
// scan_signal is already `true` (set by the caller before spawning)
|
||||
// so waiters see "scanning" even before this thread is scheduled.
|
||||
info!("Starting initial file scan");
|
||||
|
||||
let mut git_workdir = None;
|
||||
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency) {
|
||||
Ok(sync) => {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Scan completed but picker was replaced, discarding results");
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Initial filesystem scan completed: found {} files",
|
||||
sync.files.len()
|
||||
);
|
||||
|
||||
git_workdir = sync.git_workdir.clone();
|
||||
|
||||
// Write results into the provided shared handle.
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.sync_data = sync;
|
||||
}
|
||||
});
|
||||
|
||||
if write_result.is_none() {
|
||||
error!("Failed to write scan results into picker");
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
|
||||
if warmup_mmap_cache
|
||||
&& !cancelled.load(Ordering::Acquire)
|
||||
&& let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
warmup_mmaps(picker.sync_data.files());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Initial scan failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
|
||||
// Don't create a watcher if this picker instance was already replaced
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, skipping background watcher creation");
|
||||
return;
|
||||
}
|
||||
|
||||
match BackgroundWatcher::new(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
) {
|
||||
Ok(watcher) => {
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
// Final cancellation check: if the picker was replaced between
|
||||
// watcher creation and this write, drop the watcher instead of
|
||||
// storing it in the wrong picker.
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, dropping orphaned watcher");
|
||||
drop(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.background_watcher = Some(watcher);
|
||||
}
|
||||
});
|
||||
|
||||
if write_result.is_none() {
|
||||
error!("Failed to store background watcher in picker");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to initialize background file watcher: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// the debouncer keeps running in its own thread
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
|
||||
fn warmup_mmaps(files: &[FileItem]) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn scan_filesystem(
|
||||
base_path: &Path,
|
||||
synced_files_count: &Arc<AtomicUsize>,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<FileSync, Error> {
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
use std::thread;
|
||||
|
||||
let scan_start = std::time::Instant::now();
|
||||
info!("SCAN: Starting parallel filesystem scan and git status");
|
||||
|
||||
// run separate thread for git status because it effectively does another separate file
|
||||
// traversal which could be pretty slow on large repos (in general 300-500ms)
|
||||
thread::scope(|s| {
|
||||
let git_handle = s.spawn(|| {
|
||||
let git_workdir = Repository::discover(base_path)
|
||||
.ok()
|
||||
.and_then(|repo| repo.workdir().map(Path::to_path_buf));
|
||||
|
||||
if let Some(ref git_dir) = git_workdir {
|
||||
debug!("Git repository found at: {}", git_dir.display());
|
||||
} else {
|
||||
debug!("No git repository found for path: {}", base_path.display());
|
||||
}
|
||||
|
||||
let status_cache = GitStatusCache::read_git_status(
|
||||
git_workdir.as_deref(),
|
||||
// do not include unmodified here to avoid extra cost
|
||||
// we are treating all missing files as unmodified
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.exclude_submodules(true),
|
||||
);
|
||||
|
||||
(git_workdir, status_cache)
|
||||
});
|
||||
|
||||
let walker = WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.build_parallel();
|
||||
|
||||
let walker_start = std::time::Instant::now();
|
||||
info!("SCAN: Starting file walker");
|
||||
|
||||
let files = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
walker.run(|| {
|
||||
let files = Arc::clone(&files);
|
||||
let counter = Arc::clone(synced_files_count);
|
||||
let base_path = base_path.to_path_buf();
|
||||
|
||||
Box::new(move |result| {
|
||||
if let Ok(entry) = result
|
||||
&& entry.file_type().is_some_and(|ft| ft.is_file())
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if is_git_file(path) {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
|
||||
let file_item = FileItem::new(
|
||||
path.to_path_buf(),
|
||||
&base_path,
|
||||
None, // Git status will be added after join
|
||||
);
|
||||
|
||||
if let Ok(mut files_vec) = files.lock() {
|
||||
files_vec.push(file_item);
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
let mut files = Arc::try_unwrap(files).unwrap().into_inner().unwrap();
|
||||
let walker_time = walker_start.elapsed();
|
||||
info!("SCAN: File walking completed in {:?}", walker_time);
|
||||
|
||||
let (git_workdir, git_cache) = git_handle.join().map_err(|_| {
|
||||
error!("Failed to join git status thread");
|
||||
Error::ThreadPanic
|
||||
})?;
|
||||
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
files
|
||||
.par_iter_mut()
|
||||
.try_for_each(|file| -> Result<(), Error> {
|
||||
if let Some(git_cache) = &git_cache {
|
||||
file.git_status = git_cache.lookup_status(&file.path);
|
||||
}
|
||||
|
||||
if let Some(frecency) = frecency.as_ref() {
|
||||
file.update_frecency_scores(frecency)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let total_time = scan_start.elapsed();
|
||||
info!(
|
||||
"SCAN: Total scan time {:?} for {} files",
|
||||
total_time,
|
||||
files.len()
|
||||
);
|
||||
|
||||
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
|
||||
Ok(FileSync { files, git_workdir })
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_git_file(path: &Path) -> bool {
|
||||
path.to_str().is_some_and(|path| {
|
||||
if cfg!(target_family = "windows") {
|
||||
path.contains("\\.git\\")
|
||||
} else {
|
||||
path.contains("/.git/")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_key::FileKey;
|
||||
use heed::{
|
||||
types::{Bytes, SerdeBincode},
|
||||
EnvFlags,
|
||||
};
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::{error::Error, git::is_modified_status};
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use std::collections::VecDeque;
|
||||
use heed::{
|
||||
EnvFlags,
|
||||
types::{Bytes, SerdeBincode},
|
||||
};
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{collections::VecDeque, path::Path};
|
||||
|
||||
const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
|
||||
const SECONDS_PER_DAY: f64 = 86400.0;
|
||||
@@ -19,14 +19,27 @@ pub struct FrecencyTracker {
|
||||
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
|
||||
}
|
||||
|
||||
const ACCESS_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(12, 60 * 2), // 2 minutes
|
||||
(6, 60 * 10), // 10 minutes
|
||||
const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(16, 60 * 2), // 2 minutes
|
||||
(8, 60 * 15), // 15 minutes
|
||||
(4, 60 * 60), // 1 hour
|
||||
(2, 60 * 60 * 24), // 1 day
|
||||
(1, 60 * 60 * 24 * 7), // 1 week
|
||||
];
|
||||
|
||||
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)?;
|
||||
@@ -52,9 +65,10 @@ impl FrecencyTracker {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_accesses(&self, file_key: &FileKey) -> Result<Option<VecDeque<u64>>, Error> {
|
||||
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let key_hash = Self::path_to_hash_bytes(&file_key.path);
|
||||
|
||||
let key_hash = Self::path_to_hash_bytes(path)?;
|
||||
self.db.get(&rtxn, &key_hash).map_err(Error::DbRead)
|
||||
}
|
||||
|
||||
@@ -65,15 +79,19 @@ impl FrecencyTracker {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn path_to_hash_bytes(path: &str) -> [u8; 32] {
|
||||
*blake3::hash(path.as_bytes()).as_bytes()
|
||||
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32], Error> {
|
||||
let Some(key) = path.to_str() else {
|
||||
return Err(Error::InvalidPath(path.to_path_buf()));
|
||||
};
|
||||
|
||||
Ok(*blake3::hash(key.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
pub fn track_access(&self, file_key: &FileKey) -> Result<(), Error> {
|
||||
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
let key_hash = Self::path_to_hash_bytes(&file_key.path);
|
||||
let mut accesses = self.get_accesses(file_key)?.unwrap_or_default();
|
||||
let key_hash = Self::path_to_hash_bytes(path)?;
|
||||
let mut accesses = self.get_accesses(path)?.unwrap_or_default();
|
||||
|
||||
let now = self.get_now();
|
||||
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
|
||||
@@ -86,6 +104,8 @@ impl FrecencyTracker {
|
||||
}
|
||||
|
||||
accesses.push_back(now);
|
||||
tracing::debug!(?path, accesses = accesses.len(), "Tracking access");
|
||||
|
||||
self.db
|
||||
.put(&mut wtxn, &key_hash, &accesses)
|
||||
.map_err(Error::DbWrite)?;
|
||||
@@ -95,10 +115,11 @@ impl FrecencyTracker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_access_score(&self, file_key: &FileKey) -> i64 {
|
||||
pub fn get_access_score(&self, file_path: &Path) -> i64 {
|
||||
let accesses = self
|
||||
.get_accesses(file_key)
|
||||
.unwrap_or(None)
|
||||
.get_accesses(file_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
if accesses.is_empty() {
|
||||
@@ -129,23 +150,38 @@ impl FrecencyTracker {
|
||||
normalized_frecency.round() as i64
|
||||
}
|
||||
|
||||
/// Calculate modification frecency score (0-12 points, git-aware)
|
||||
pub fn get_modification_score(&self, modified_time: u64, git_status: &str) -> i64 {
|
||||
let git_shows_changes = matches!(
|
||||
git_status,
|
||||
"modified" | "staged_modified" | "untracked" | "staged_new"
|
||||
);
|
||||
|
||||
if !git_shows_changes {
|
||||
return 0; // No modification score for clean/unchanged files
|
||||
/// Calculating modification score but only if the file is modified in the current git dir
|
||||
pub fn get_modification_score(
|
||||
&self,
|
||||
modified_time: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
) -> i64 {
|
||||
let is_modified_git_status = git_status.is_some_and(is_modified_status);
|
||||
if !is_modified_git_status {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let now = self.get_now();
|
||||
let duration_since = now.saturating_sub(modified_time);
|
||||
|
||||
for (base_points, threshold_seconds) in ACCESS_THRESHOLDS {
|
||||
if duration_since <= threshold_seconds {
|
||||
return base_points * 2;
|
||||
for i in 0..MODIFICATION_THRESHOLDS.len() {
|
||||
let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i];
|
||||
|
||||
if duration_since <= current_threshold {
|
||||
if i == 0 || duration_since == current_threshold {
|
||||
return current_points;
|
||||
}
|
||||
|
||||
let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1];
|
||||
|
||||
let time_range = current_threshold - prev_threshold;
|
||||
let time_offset = duration_since - prev_threshold;
|
||||
let points_diff = prev_points - current_points;
|
||||
|
||||
let interpolated_score =
|
||||
prev_points - (points_diff * time_offset as i64) / time_range as i64;
|
||||
|
||||
return interpolated_score;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,4 +257,48 @@ mod tests {
|
||||
old_score
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modification_score_interpolation() {
|
||||
let temp_dir = std::env::temp_dir().join("fff_test_interpolation");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
let tracker = FrecencyTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
|
||||
let current_time = tracker.get_now();
|
||||
let git_status = Some(git2::Status::WT_MODIFIED);
|
||||
|
||||
// At 5 minutes: should interpolate between 16 and 8 points
|
||||
let five_minutes_ago = current_time - (5 * 60);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, git_status);
|
||||
|
||||
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
|
||||
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
|
||||
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
|
||||
|
||||
let two_minutes_ago = current_time - (2 * 60);
|
||||
let score = tracker.get_modification_score(two_minutes_ago, git_status);
|
||||
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
|
||||
|
||||
let fifteen_minutes_ago = current_time - (15 * 60);
|
||||
let score = tracker.get_modification_score(fifteen_minutes_ago, git_status);
|
||||
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
|
||||
|
||||
// At 12 hours: should interpolate between 4 and 2 points
|
||||
let twelve_hours_ago = current_time - (12 * 60 * 60);
|
||||
let score = tracker.get_modification_score(twelve_hours_ago, git_status);
|
||||
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
|
||||
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
|
||||
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
|
||||
|
||||
// at 18 hours for more significant interpolation
|
||||
let eighteen_hours_ago = current_time - (18 * 60 * 60);
|
||||
let score = tracker.get_modification_score(eighteen_hours_ago, git_status);
|
||||
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
|
||||
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
|
||||
|
||||
let score = tracker.get_modification_score(five_minutes_ago, None);
|
||||
assert_eq!(score, 0, "No git status should return 0");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use crate::error::Result;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
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
|
||||
/// the status is `None` otherwise contains only actual file statuses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStatusCache(Vec<(PathBuf, Status)>);
|
||||
|
||||
impl IntoIterator for GitStatusCache {
|
||||
type Item = (PathBuf, Status);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl GitStatusCache {
|
||||
pub fn statuses_len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
|
||||
self.0
|
||||
.binary_search_by(|(path, _)| path.as_path().cmp(full_path))
|
||||
.ok()
|
||||
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
|
||||
}
|
||||
|
||||
#[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 {
|
||||
if let Some(entry_path) = entry.path() {
|
||||
let full_path = repo_path.join(entry_path);
|
||||
entries.push((full_path, entry.status()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self(entries))
|
||||
}
|
||||
|
||||
pub fn read_git_status(
|
||||
git_workdir: Option<&Path>,
|
||||
status_options: &mut StatusOptions,
|
||||
) -> Option<Self> {
|
||||
let git_workdir = git_workdir.as_ref()?;
|
||||
let repository = Repository::open(git_workdir).ok()?;
|
||||
|
||||
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],
|
||||
) -> Result<Self> {
|
||||
if paths.is_empty() {
|
||||
return Ok(Self(vec![]));
|
||||
}
|
||||
|
||||
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)
|
||||
// when reading partial status it's important to include all files requested
|
||||
.include_unmodified(true);
|
||||
|
||||
for path in paths {
|
||||
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
|
||||
}
|
||||
|
||||
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
|
||||
debug!(
|
||||
status_len = git_status_cache.statuses_len(),
|
||||
"Multiple files git status"
|
||||
);
|
||||
|
||||
Ok(git_status_cache)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_modified_status(status: Status) -> bool {
|
||||
status.intersects(
|
||||
Status::WT_MODIFIED
|
||||
| Status::INDEX_MODIFIED
|
||||
| Status::WT_NEW
|
||||
| Status::INDEX_NEW
|
||||
| Status::WT_RENAMED,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
match status {
|
||||
None => "clean",
|
||||
Some(status) => {
|
||||
if status.contains(Status::WT_NEW) {
|
||||
"untracked"
|
||||
} else if status.contains(Status::WT_MODIFIED) {
|
||||
"modified"
|
||||
} else if status.contains(Status::WT_DELETED) {
|
||||
"deleted"
|
||||
} else if status.contains(Status::WT_RENAMED) {
|
||||
"renamed"
|
||||
} else if status.contains(Status::INDEX_NEW) {
|
||||
"staged_new"
|
||||
} else if status.contains(Status::INDEX_MODIFIED) {
|
||||
"staged_modified"
|
||||
} else if status.contains(Status::INDEX_DELETED) {
|
||||
"staged_deleted"
|
||||
} else if status.contains(Status::IGNORED) {
|
||||
"ignored"
|
||||
} else if status.contains(Status::CURRENT) || status.is_empty() {
|
||||
"clean"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
//! fff-core - High-performance file finder library
|
||||
//!
|
||||
//! This crate provides the core file indexing and fuzzy search functionality.
|
||||
//!
|
||||
//! # State management
|
||||
//!
|
||||
//! All state is instance-based. Callers create their own `SharedPicker` /
|
||||
//! `SharedFrecency` / `SharedQueryTracker` and pass them into
|
||||
//! `FilePicker::new_with_shared_state`. Multiple independent instances can
|
||||
//! coexist in the same process.
|
||||
|
||||
mod background_watcher;
|
||||
pub mod constraints;
|
||||
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 query_tracker::QueryTracker;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
|
||||
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
|
||||
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
|
||||
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use fff_query_parser::{
|
||||
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
|
||||
};
|
||||
pub use file_picker::{FuzzySearchOptions, ScanProgress};
|
||||
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 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
|
||||
};
|
||||
|
||||
let current_dir = if let Some(parent) = std::path::Path::new(current_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let candidate_dir = if let Some(parent) = std::path::Path::new(candidate_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if current_dir == candidate_dir {
|
||||
return 0; // Same directory, no penalty
|
||||
}
|
||||
|
||||
let current_parts: Vec<&str> = current_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let candidate_parts: Vec<&str> = candidate_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let common_len = current_parts
|
||||
.iter()
|
||||
.zip(candidate_parts.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
|
||||
let current_depth_from_common = current_parts.len() - common_len;
|
||||
|
||||
if current_depth_from_common == 0 {
|
||||
return 0; // Current file is at the common ancestor level
|
||||
}
|
||||
|
||||
let penalty = -(current_depth_from_common as i32);
|
||||
|
||||
penalty.max(-20)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_family = "windows"))]
|
||||
fn test_calculate_distance_penalty() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(None, "examples/user/test/mod.rs"),
|
||||
0
|
||||
);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/main.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
),
|
||||
0
|
||||
);
|
||||
//
|
||||
// One level apart
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/subdir/file.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
//
|
||||
// Different subdirectories (same parent)
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/dir1/file.rs"),
|
||||
"examples/user/test/dir2/mod.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/lib/audio-announce.rs"),
|
||||
"examples/audio-announce/src/main.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/audio-announce.rs"),
|
||||
"examples/pixel/src/main.rs"
|
||||
),
|
||||
-2
|
||||
);
|
||||
|
||||
// Root level files
|
||||
assert_eq!(calculate_distance_penalty(Some("main.rs"), "lib.rs"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_family = "windows")]
|
||||
fn distance_penalty_works_on_windows() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(None, "examples\\user\\test\\mod.rs"),
|
||||
0
|
||||
);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\main.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
),
|
||||
0
|
||||
);
|
||||
//
|
||||
// One level apart
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\subdir\\file.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,83 @@
|
||||
[package]
|
||||
name = "fff-nvim"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "jemalloc_profile"
|
||||
path = "src/bin/jemalloc_profile.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "search_profiler"
|
||||
path = "src/bin/search_profiler.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_search_only"
|
||||
path = "src/bin/bench_search_only.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "grep_profiler"
|
||||
path = "src/bin/grep_profiler.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "grep_vs_rg"
|
||||
path = "src/bin/grep_vs_rg.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-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"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
|
||||
[[bench]]
|
||||
name = "indexing_and_search"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Initialize tracing to output to console
|
||||
fn init_tracing() {
|
||||
// use tracing_subscriber::EnvFilter;
|
||||
// use tracing_subscriber::fmt;
|
||||
// let _ = fmt()
|
||||
// .with_env_filter(
|
||||
// EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
// )
|
||||
// .with_target(false)
|
||||
// .with_thread_ids(true)
|
||||
// .with_line_number(true)
|
||||
// .try_init();
|
||||
}
|
||||
|
||||
/// Initialize FilePicker using shared state
|
||||
fn init_file_picker_internal(
|
||||
path: &str,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), String> {
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string(),
|
||||
false,
|
||||
Arc::clone(shared_picker),
|
||||
Arc::clone(shared_frecency),
|
||||
)
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
|
||||
}
|
||||
|
||||
/// Helper function to wait for scanning to complete and get file count
|
||||
fn wait_for_scan_completion(
|
||||
shared_picker: &SharedPicker,
|
||||
timeout_secs: u64,
|
||||
) -> Result<usize, String> {
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut last_log = std::time::Instant::now();
|
||||
let mut iteration = 0;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
{
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let is_scanning = picker.is_scan_active();
|
||||
let file_count = picker.get_files().len();
|
||||
|
||||
// Log progress every 2 seconds
|
||||
if last_log.elapsed() >= Duration::from_secs(2) {
|
||||
eprintln!(
|
||||
" [{:.1}s] Scanning: {}, Files: {}, Iterations: {}",
|
||||
start.elapsed().as_secs_f32(),
|
||||
is_scanning,
|
||||
file_count,
|
||||
iteration
|
||||
);
|
||||
last_log = std::time::Instant::now();
|
||||
}
|
||||
|
||||
if !is_scanning && file_count > 0 {
|
||||
eprintln!(
|
||||
" ✓ Scan complete after {:.2}s: {} files found",
|
||||
start.elapsed().as_secs_f32(),
|
||||
file_count
|
||||
);
|
||||
return Ok(file_count);
|
||||
}
|
||||
} else {
|
||||
if iteration % 100 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] FilePicker is None (iteration {})",
|
||||
start.elapsed().as_secs_f32(),
|
||||
iteration
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if start.elapsed() > timeout {
|
||||
return Err(format!(
|
||||
"Scan timed out after {} seconds (iteration {})",
|
||||
timeout_secs, iteration
|
||||
));
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files from the shared picker
|
||||
fn get_files_snapshot(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().to_vec())
|
||||
} else {
|
||||
Err("FilePicker not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up shared state
|
||||
fn cleanup_shared_state(shared_picker: &SharedPicker) {
|
||||
if let Ok(mut picker_guard) = shared_picker.write() {
|
||||
if let Some(mut picker) = picker_guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize FilePicker once and return files snapshot
|
||||
fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String> {
|
||||
init_tracing();
|
||||
|
||||
let big_repo_path = PathBuf::from("./big-repo");
|
||||
if !big_repo_path.exists() {
|
||||
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
|
||||
}
|
||||
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&big_repo_path)
|
||||
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
eprintln!(" Path: {:?}", canonical_path);
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
init_file_picker_internal(
|
||||
&canonical_path.to_string_lossy(),
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
)?;
|
||||
|
||||
eprintln!(" Waiting for background scan to complete...");
|
||||
let file_count = wait_for_scan_completion(&shared_picker, 120)?;
|
||||
eprintln!(
|
||||
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
|
||||
file_count
|
||||
);
|
||||
|
||||
let files = get_files_snapshot(&shared_picker)?;
|
||||
Ok((files, shared_picker, shared_frecency))
|
||||
}
|
||||
|
||||
/// Benchmark for indexing the big-repo directory
|
||||
fn bench_indexing(c: &mut Criterion) {
|
||||
init_tracing();
|
||||
|
||||
let big_repo_path = PathBuf::from("./big-repo");
|
||||
if !big_repo_path.exists() {
|
||||
eprintln!(
|
||||
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = match fff_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(|| {
|
||||
let sp: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let sf: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
|
||||
.expect("Failed to init FilePicker");
|
||||
|
||||
match wait_for_scan_completion(&sp, 120) {
|
||||
Ok(file_count) => {
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
|
||||
cleanup_shared_state(&sp);
|
||||
file_count
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Error: {}", e);
|
||||
cleanup_shared_state(&sp);
|
||||
0
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark for searching with various query patterns
|
||||
fn bench_search_queries(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprint!("Failed to setup picker {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("search");
|
||||
group.sample_size(100);
|
||||
|
||||
let test_queries = vec![
|
||||
("short", "mod"),
|
||||
("medium", "controller"),
|
||||
("long", "user_authentication"),
|
||||
("typo", "contrlr"),
|
||||
("partial", "src/lib"),
|
||||
];
|
||||
|
||||
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),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search with different thread counts
|
||||
fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("thread_scaling");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "controller";
|
||||
let 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),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: threads,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search with different result limits
|
||||
fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("result_limits");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "mod";
|
||||
let 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),
|
||||
None,
|
||||
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),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search performance with different ordering modes
|
||||
fn bench_search_ordering(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("ordering");
|
||||
group.sample_size(100);
|
||||
|
||||
let 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),
|
||||
None,
|
||||
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),
|
||||
None,
|
||||
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"),
|
||||
None,
|
||||
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"),
|
||||
None,
|
||||
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"),
|
||||
None,
|
||||
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"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark pagination: first page vs deep page
|
||||
fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("pagination");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "mod";
|
||||
let 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),
|
||||
None,
|
||||
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),
|
||||
None,
|
||||
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),
|
||||
None,
|
||||
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_core::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"
|
||||
);
|
||||
}
|
||||
@@ -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 ===");
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
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.matches.len(), result.total_files_searched)
|
||||
}
|
||||
|
||||
/// Benchmark a query with multiple iterations
|
||||
fn bench_query(&self, query: &str, iterations: usize) -> (BenchStats, usize, usize) {
|
||||
let mut stats = BenchStats::new();
|
||||
let mut last_matches = 0;
|
||||
let mut last_files_searched = 0;
|
||||
|
||||
for _ in 0..iterations {
|
||||
let (elapsed, matches, files_searched) = self.run_once(query);
|
||||
stats.push(elapsed);
|
||||
last_matches = matches;
|
||||
last_files_searched = files_searched;
|
||||
}
|
||||
|
||||
(stats, last_matches, last_files_searched)
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_dur(d: Duration) -> String {
|
||||
let us = d.as_micros();
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", d.as_secs_f64())
|
||||
} else if us > 1000 {
|
||||
format!("{:.2}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}us", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn print_row(name: &str, stats: &BenchStats, matches: usize, files_searched: usize, iters: usize) {
|
||||
eprintln!(
|
||||
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
|
||||
name,
|
||||
fmt_dur(stats.mean()),
|
||||
fmt_dur(stats.median()),
|
||||
fmt_dur(stats.p95()),
|
||||
fmt_dur(stats.p99()),
|
||||
fmt_dur(stats.min()),
|
||||
fmt_dur(stats.max()),
|
||||
matches,
|
||||
files_searched,
|
||||
iters,
|
||||
);
|
||||
}
|
||||
|
||||
fn print_header() {
|
||||
eprintln!(
|
||||
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
|
||||
"Name", "Mean", "Median", "P95", "P99", "Min", "Max", "Match", "Files", "Iter"
|
||||
);
|
||||
eprintln!(
|
||||
" {:-<24}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<6}-+-{:-<6}-+-{:-<4}",
|
||||
"", "", "", "", "", "", "", "", "", ""
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Parse args
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
|
||||
args.get(idx + 1)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("./big-repo")
|
||||
} else {
|
||||
"./big-repo"
|
||||
};
|
||||
|
||||
let repo = std::path::PathBuf::from(repo_path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Repository not found at: {}", repo_path);
|
||||
eprintln!("Usage: grep_profiler [--path /path/to/large/repo]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff_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");
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
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.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
/// fff paginated: first 50 results only (real UI scenario).
|
||||
fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct IterStats {
|
||||
min: Duration,
|
||||
avg: Duration,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
fn run_n<F: Fn() -> (usize, Duration)>(f: F, n: usize) -> IterStats {
|
||||
let mut times = Vec::with_capacity(n);
|
||||
let mut count = 0;
|
||||
for _ in 0..n {
|
||||
let (c, d) = f();
|
||||
count = c;
|
||||
times.push(d);
|
||||
}
|
||||
times.sort();
|
||||
let min = times[0];
|
||||
let avg = times.iter().sum::<Duration>() / n as u32;
|
||||
IterStats { min, avg, count }
|
||||
}
|
||||
|
||||
fn fmt_dur(d: Duration) -> String {
|
||||
let us = d.as_micros();
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", d.as_secs_f64())
|
||||
} else if us > 1000 {
|
||||
format!("{:.1}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}us", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio_str(a: Duration, b: Duration) -> String {
|
||||
if a.is_zero() || b.is_zero() {
|
||||
return "-".to_string();
|
||||
}
|
||||
let r = b.as_secs_f64() / a.as_secs_f64();
|
||||
format!("{:.1}x", r)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
|
||||
args.get(idx + 1)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("./big-repo")
|
||||
} else {
|
||||
"./big-repo"
|
||||
};
|
||||
let iters = if let Some(idx) = args.iter().position(|a| a == "--iters") {
|
||||
args.get(idx + 1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_ITERS)
|
||||
} else {
|
||||
DEFAULT_ITERS
|
||||
};
|
||||
|
||||
let repo = std::path::PathBuf::from(repo_path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Repository not found at: {}", repo_path);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff_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!();
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn get_mem_stat() -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
|
||||
// Use system memory info since jemalloc-ctl conflicts
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command;
|
||||
let pid = std::process::id();
|
||||
let output = Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
let rss_str = String::from_utf8(output.stdout)?;
|
||||
let rss_kb: usize = rss_str.trim().parse()?;
|
||||
let rss_bytes = rss_kb * 1024;
|
||||
Ok((rss_bytes, rss_bytes, rss_bytes))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let pid = std::process::id();
|
||||
let status_path = format!("/proc/{}/status", pid);
|
||||
let content = std::fs::read_to_string(status_path)?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("VmRSS:") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if let Ok(rss_kb) = parts[1].parse::<usize>()
|
||||
&& parts.len() >= 2
|
||||
{
|
||||
let rss_bytes = rss_kb * 1024;
|
||||
return Ok((rss_bytes, rss_bytes, rss_bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("Could not find VmRSS in /proc/pid/status".into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
Ok((0, 0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: usize) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit_index = 0;
|
||||
|
||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
format!("{:.2} {}", size, UNITS[unit_index])
|
||||
}
|
||||
|
||||
fn test_search_memory_pattern(
|
||||
shared_picker: &SharedPicker,
|
||||
name: &str,
|
||||
iterations: usize,
|
||||
query_pattern: impl Fn(usize) -> String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🧪 {}", name);
|
||||
|
||||
let (initial_allocated, initial_active, initial_resident) = get_mem_stat()?;
|
||||
println!(
|
||||
" Initial - Allocated: {}, Active: {}, Resident: {}",
|
||||
format_bytes(initial_allocated),
|
||||
format_bytes(initial_active),
|
||||
format_bytes(initial_resident)
|
||||
);
|
||||
|
||||
let mut max_allocated = initial_allocated;
|
||||
let mut max_active = initial_active;
|
||||
let mut max_resident = initial_resident;
|
||||
|
||||
for i in 0..iterations {
|
||||
let query = query_pattern(i);
|
||||
|
||||
let (result_count, _total_matched) = {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(&query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
parsed,
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if i % 100 == 0 {
|
||||
let (allocated, active, resident) = get_mem_stat()?;
|
||||
max_allocated = max_allocated.max(allocated);
|
||||
max_active = max_active.max(active);
|
||||
max_resident = max_resident.max(resident);
|
||||
|
||||
if i % 500 == 0 {
|
||||
println!(
|
||||
" Iteration {}: {} results, Allocated: {} (+{})",
|
||||
i,
|
||||
result_count,
|
||||
format_bytes(allocated),
|
||||
format_bytes(allocated.saturating_sub(initial_allocated))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if i % 200 == 199 {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
let (final_allocated, final_active, final_resident) = get_mem_stat()?;
|
||||
|
||||
println!(
|
||||
" Final - Allocated: {} (+{}), Active: {} (+{}), Resident: {} (+{})",
|
||||
format_bytes(final_allocated),
|
||||
format_bytes(final_allocated.saturating_sub(initial_allocated)),
|
||||
format_bytes(final_active),
|
||||
format_bytes(final_active.saturating_sub(initial_active)),
|
||||
format_bytes(final_resident),
|
||||
format_bytes(final_resident.saturating_sub(initial_resident))
|
||||
);
|
||||
|
||||
println!(
|
||||
" Peak - Allocated: {} (+{}), Active: {} (+{}), Resident: {} (+{})",
|
||||
format_bytes(max_allocated),
|
||||
format_bytes(max_allocated.saturating_sub(initial_allocated)),
|
||||
format_bytes(max_active),
|
||||
format_bytes(max_active.saturating_sub(initial_active)),
|
||||
format_bytes(max_resident),
|
||||
format_bytes(max_resident.saturating_sub(initial_resident))
|
||||
);
|
||||
|
||||
let growth_per_search =
|
||||
(final_allocated.saturating_sub(initial_allocated)) as f64 / iterations as f64;
|
||||
println!(
|
||||
" Average growth per search: {:.2} bytes",
|
||||
growth_per_search
|
||||
);
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let base_path = if args.len() > 1 {
|
||||
args[1].clone()
|
||||
} else {
|
||||
env::current_dir()?.to_str().unwrap_or(".").to_string()
|
||||
};
|
||||
|
||||
println!("🧪 FFF.nvim Jemalloc Memory Profiler");
|
||||
println!("====================================");
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize FilePicker
|
||||
println!("Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Wait for initial scan
|
||||
println!("Waiting for file scan...");
|
||||
loop {
|
||||
if let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
&& !picker.is_scan_active()
|
||||
&& !picker.get_files().is_empty()
|
||||
{
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let file_count = {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
guard.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
println!("📊 Found {} files", file_count);
|
||||
|
||||
let (initial_allocated, initial_active, initial_resident) = get_mem_stat()?;
|
||||
println!("🧠 Baseline memory:");
|
||||
println!(" Allocated: {}", format_bytes(initial_allocated));
|
||||
println!(" Active: {}", format_bytes(initial_active));
|
||||
println!(" Resident: {}", format_bytes(initial_resident));
|
||||
println!();
|
||||
|
||||
// Test different memory patterns
|
||||
|
||||
// 1. Repeated same query - should have minimal growth if caching works
|
||||
test_search_memory_pattern(&shared_picker, "Same Query Repeated (1000x)", 1000, |_| {
|
||||
"test".to_string()
|
||||
})?;
|
||||
|
||||
// 2. Cycling through different queries
|
||||
test_search_memory_pattern(&shared_picker, "Cycling Queries (1000x)", 1000, |i| {
|
||||
let queries = [
|
||||
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
|
||||
];
|
||||
queries[i % queries.len()].to_string()
|
||||
})?;
|
||||
|
||||
// 3. Unique queries each time - worst case for any caching
|
||||
test_search_memory_pattern(&shared_picker, "Unique Queries (500x)", 500, |i| {
|
||||
format!("unique_query_{}", i)
|
||||
})?;
|
||||
|
||||
// 4. Queries that return many results
|
||||
test_search_memory_pattern(
|
||||
&shared_picker,
|
||||
"High Result Count (500x)",
|
||||
500,
|
||||
|_| "a".to_string(), // Single character likely to match many files
|
||||
)?;
|
||||
|
||||
// 5. Queries with no results
|
||||
test_search_memory_pattern(&shared_picker, "No Results (500x)", 500, |_| {
|
||||
"zzzz_no_match_expected".to_string()
|
||||
})?;
|
||||
|
||||
// 6. Long intensive test
|
||||
test_search_memory_pattern(&shared_picker, "Long Intensive Test (2000x)", 2000, |i| {
|
||||
let patterns = [
|
||||
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
|
||||
];
|
||||
format!("{}{}", patterns[i % patterns.len()], i % 100)
|
||||
})?;
|
||||
|
||||
let (final_allocated, final_active, final_resident) = get_mem_stat()?;
|
||||
|
||||
println!("🏁 Final Memory Stats:");
|
||||
println!(
|
||||
" Allocated: {} (growth: {})",
|
||||
format_bytes(final_allocated),
|
||||
format_bytes(final_allocated.saturating_sub(initial_allocated))
|
||||
);
|
||||
println!(
|
||||
" Active: {} (growth: {})",
|
||||
format_bytes(final_active),
|
||||
format_bytes(final_active.saturating_sub(initial_active))
|
||||
);
|
||||
println!(
|
||||
" Resident: {} (growth: {})",
|
||||
format_bytes(final_resident),
|
||||
format_bytes(final_resident.saturating_sub(initial_resident))
|
||||
);
|
||||
|
||||
let total_searches = 5500;
|
||||
let avg_growth =
|
||||
(final_allocated.saturating_sub(initial_allocated)) as f64 / total_searches as f64;
|
||||
println!(" Average growth per search: {:.2} bytes", avg_growth);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{
|
||||
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
fn wait_for_scan(shared_picker: &SharedPicker, 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 = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let is_scanning = picker.is_scan_active();
|
||||
let file_count = picker.get_files().len();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files snapshot from shared state
|
||||
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
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");
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
.expect("Failed to init FilePicker");
|
||||
|
||||
// Give background thread time to start
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
eprintln!("Waiting for scan to complete...");
|
||||
let file_count = wait_for_scan(&shared_picker, 120).expect("Failed to wait for scan");
|
||||
eprintln!("✓ Indexed {} files\n", file_count);
|
||||
|
||||
let files = get_files(&shared_picker).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));
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn get_memory_usage() -> Result<u64, Box<dyn std::error::Error>> {
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
return Err("Memory usage check is only supported on Linux and macOS".into());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let pid = std::process::id();
|
||||
use std::process::Command;
|
||||
let output = Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
let rss_str = String::from_utf8(output.stdout)?;
|
||||
let rss_kb: u64 = rss_str.trim().parse()?;
|
||||
|
||||
Ok(rss_kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let pid = std::process::id();
|
||||
let status_path = format!("/proc/{}/status", pid);
|
||||
let content = std::fs::read_to_string(status_path)?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("VmRSS:") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if let Ok(rss_kb) = parts[1].parse::<u64>()
|
||||
&& parts.len() >= 2
|
||||
{
|
||||
return Ok(rss_kb * 1024); // Convert KB to bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not find VmRSS in /proc/pid/status".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit_index = 0;
|
||||
|
||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
format!("{:.2} {}", size, UNITS[unit_index])
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !cfg!(target_os = "linux") && !cfg!(target_os = "macos") {
|
||||
eprintln!("This test is only supported on Linux and macOS.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let base_path = if args.len() > 1 {
|
||||
args[1].clone()
|
||||
} else {
|
||||
env::current_dir()?.to_str().unwrap_or(".").to_string()
|
||||
};
|
||||
|
||||
println!("🧪 FFF.nvim Memory Leak Test (Using Crate)");
|
||||
println!("==========================================");
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize the file picker
|
||||
println!("📁 Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Wait for initial scan to complete
|
||||
println!("⏳ Waiting for initial file scan to complete...");
|
||||
let mut wait_time = 0;
|
||||
let mut scan_completed = false;
|
||||
|
||||
loop {
|
||||
if let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
if !picker.is_scan_active() {
|
||||
println!("Scan inactive, checking file count...");
|
||||
let file_count = picker.get_files().len();
|
||||
if file_count > 0 {
|
||||
println!("Async scan found {} files", file_count);
|
||||
scan_completed = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
println!("Scan active, waiting...");
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
wait_time += 100;
|
||||
if wait_time > 10000 {
|
||||
println!("Timeout waiting for async scan, triggering manual scan...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If async scan didn't work, trigger a manual scan
|
||||
if !scan_completed {
|
||||
println!("Triggering manual rescan...");
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
match picker.trigger_rescan(&shared_frecency) {
|
||||
Ok(_) => println!("Manual rescan completed"),
|
||||
Err(e) => println!("Manual rescan failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let initial_file_count = {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let files = picker.get_files();
|
||||
println!("Found {} files in picker", files.len());
|
||||
if !files.is_empty() {
|
||||
println!("Sample files:");
|
||||
for (i, file) in files.iter().take(5).enumerate() {
|
||||
println!(" {}. {}", i + 1, file.relative_path);
|
||||
}
|
||||
}
|
||||
files.len()
|
||||
} else {
|
||||
println!("No picker found!");
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"📊 Initial scan complete: {} files found",
|
||||
initial_file_count
|
||||
);
|
||||
|
||||
if initial_file_count == 0 {
|
||||
eprintln!("❌ No files found in directory. Cannot proceed with memory test.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Record initial memory usage
|
||||
let initial_memory = get_memory_usage().unwrap_or(0);
|
||||
println!("🧠 Initial memory usage: {}", format_bytes(initial_memory));
|
||||
println!();
|
||||
|
||||
// Test queries to cycle through
|
||||
let test_queries = vec![
|
||||
"rs", "mod", "lib", "main", "test", "src", "lua", "cargo", "toml", "init", "config",
|
||||
"util", "file", "picker", "fuzzy", "search", "git", "fn", "struct", "impl", "pub", "use",
|
||||
"let", "match", "if", "for", "async", "await", "Result", "Error", "Vec", "String",
|
||||
"Option",
|
||||
];
|
||||
|
||||
let mut last_memory_check = Instant::now();
|
||||
let mut peak_memory = initial_memory;
|
||||
let mut memory_samples = Vec::new();
|
||||
|
||||
println!("🔥 Starting intensive search test...");
|
||||
println!("Press Ctrl+C to stop the test");
|
||||
println!();
|
||||
let mut search_count = 0;
|
||||
|
||||
while search_count < 1000 {
|
||||
search_count += 1;
|
||||
|
||||
// Perform search directly on FilePicker
|
||||
let query = test_queries[search_count % test_queries.len()];
|
||||
let max_results = 50 + (search_count % 100); // Vary result count
|
||||
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 guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let parsed = parser.parse(query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
query,
|
||||
parsed,
|
||||
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)
|
||||
} else {
|
||||
(0, search_start.elapsed())
|
||||
}
|
||||
};
|
||||
|
||||
search_count += 1;
|
||||
|
||||
// Check memory every 100 searches or every 5 seconds
|
||||
let now = Instant::now();
|
||||
if (search_count % 100 == 0
|
||||
|| now.duration_since(last_memory_check) > Duration::from_secs(5))
|
||||
&& let Ok(current_memory) = get_memory_usage()
|
||||
{
|
||||
memory_samples.push(current_memory);
|
||||
|
||||
if current_memory > peak_memory {
|
||||
peak_memory = current_memory;
|
||||
}
|
||||
|
||||
let memory_growth = current_memory.saturating_sub(initial_memory);
|
||||
|
||||
println!(
|
||||
"🔍 Search #{}: '{}' -> {} results in {:?} | Memory: {} (+{}) | Peak: {}",
|
||||
search_count,
|
||||
query,
|
||||
result_count,
|
||||
search_duration,
|
||||
format_bytes(current_memory),
|
||||
format_bytes(memory_growth),
|
||||
format_bytes(peak_memory)
|
||||
);
|
||||
|
||||
last_memory_check = now;
|
||||
|
||||
// Calculate memory growth trend over last 10 samples
|
||||
if memory_samples.len() >= 10 {
|
||||
let recent_samples = &memory_samples[memory_samples.len() - 10..];
|
||||
let first_recent = recent_samples[0];
|
||||
let last_recent = recent_samples[recent_samples.len() - 1];
|
||||
|
||||
if last_recent > first_recent {
|
||||
let recent_growth = last_recent - first_recent;
|
||||
if recent_growth > 1024 * 1024 {
|
||||
// More than 1MB growth in recent samples
|
||||
println!(
|
||||
"⚠️ POTENTIAL LEAK: Recent memory growth: {}",
|
||||
format_bytes(recent_growth)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
// Brief pause to prevent overwhelming the system
|
||||
if search_count % 10 == 0 {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
// Force cleanup test every 1000 searches
|
||||
if search_count % 1000 == 0 {
|
||||
println!("🧹 Forcing potential cleanup at search #{}", search_count);
|
||||
// Force garbage collection by creating and dropping temporary data
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
#![allow(clippy::all)]
|
||||
#![allow(dead_code)]
|
||||
#![allow(clippy::enum_variant_names)]
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let base_path = if args.len() > 1 {
|
||||
args[1].clone()
|
||||
} else {
|
||||
env::current_dir()?.to_str().unwrap_or(".").to_string()
|
||||
};
|
||||
|
||||
// Set up signal handler for graceful shutdown
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Clone for signal handler
|
||||
let picker_for_cleanup = Arc::clone(&shared_picker);
|
||||
ctrlc::set_handler(move || {
|
||||
println!("\n🛑 Received interrupt signal, shutting down...");
|
||||
if let Ok(mut guard) = picker_for_cleanup.write() {
|
||||
if let Some(mut picker) = guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
println!("🧹 FilePicker cleaned up");
|
||||
}
|
||||
}
|
||||
r.store(false, Ordering::SeqCst);
|
||||
std::process::exit(0);
|
||||
})?;
|
||||
|
||||
let mut git_stats = std::collections::HashMap::new();
|
||||
|
||||
// Initialize the file picker using shared state
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Get initial file count from shared state
|
||||
let initial_count = {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
println!("Initial file count: {}", files.len());
|
||||
|
||||
if !files.is_empty() {
|
||||
println!("Sample files:");
|
||||
for (i, file) in files.iter().take(5).enumerate() {
|
||||
println!(
|
||||
" {}. {} ({})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
format_git_status(file.git_status)
|
||||
);
|
||||
}
|
||||
if files.len() > 5 {
|
||||
println!(" ... and {} more files", files.len() - 5);
|
||||
}
|
||||
}
|
||||
files.len()
|
||||
};
|
||||
|
||||
println!("{:=<60}", "");
|
||||
println!("🔴 LIVE FILE MONITORING - Press Ctrl+C to stop");
|
||||
println!("{:=<60}", "");
|
||||
|
||||
let mut last_count = initial_count;
|
||||
let mut iteration = 0;
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
iteration += 1;
|
||||
|
||||
let current_count = {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
guard.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
if current_count != last_count {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S%.3f");
|
||||
|
||||
if current_count > last_count {
|
||||
let added = current_count - last_count;
|
||||
println!(
|
||||
"🟢 [{}] +{} files added | Total: {}",
|
||||
timestamp, added, current_count
|
||||
);
|
||||
|
||||
// Show some recently added files
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let newest_files = files.iter().rev().take(added.min(3));
|
||||
for file in newest_files {
|
||||
println!(" ➕ {}", file.relative_path);
|
||||
}
|
||||
} else {
|
||||
let removed = last_count - current_count;
|
||||
println!(
|
||||
"🔴 [{}] -{} files removed | Total: {}",
|
||||
timestamp, removed, current_count
|
||||
);
|
||||
}
|
||||
|
||||
last_count = current_count;
|
||||
}
|
||||
|
||||
if iteration % 20 == 0 {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S");
|
||||
println!(
|
||||
"💓 [{}] Heartbeat - {} files cached, watcher active",
|
||||
timestamp, current_count
|
||||
);
|
||||
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let current_files = guard.as_ref().unwrap().get_files();
|
||||
|
||||
git_stats.clear();
|
||||
for file in current_files {
|
||||
let status = format_git_status(file.git_status);
|
||||
*git_stats.entry(status).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
if !git_stats.is_empty() {
|
||||
print!(" 📊 Git status: ");
|
||||
for (status, count) in &git_stats {
|
||||
print!("{}:{} ", status, count);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
if iteration % 40 == 0 {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S");
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse("rs");
|
||||
let search_results = FilePicker::fuzzy_search(
|
||||
files,
|
||||
"rs",
|
||||
parsed,
|
||||
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",
|
||||
timestamp,
|
||||
search_results.items.len()
|
||||
);
|
||||
for (i, (file, score)) in search_results
|
||||
.items
|
||||
.iter()
|
||||
.zip(search_results.scores.iter())
|
||||
.take(3)
|
||||
.enumerate()
|
||||
{
|
||||
println!(
|
||||
" {}. {} (score: {})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
score.total
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
// Clean up before exit
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(mut picker) = guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
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, SharedFrecency,
|
||||
SharedPicker, SharedQueryTracker,
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use mlua::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use path_shortening::PathShortenStrategy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
mod error;
|
||||
mod log;
|
||||
mod lua_types;
|
||||
mod path_shortening;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
// the global state for neovim lives here for efficiency
|
||||
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
|
||||
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
|
||||
pub fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
if frecency.is_some() {
|
||||
*frecency = None;
|
||||
}
|
||||
*frecency =
|
||||
Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock).into_lua_result()?);
|
||||
tracing::info!("Frecency database initialized at {}", frecency_db_path);
|
||||
|
||||
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 guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if guard.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
false,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)
|
||||
.into_lua_result()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
// Cancel and stop the old picker under a single write lock to avoid
|
||||
// a window where FILE_PICKER is None (which causes FilePickerMissing
|
||||
// errors if the UI is searching concurrently).
|
||||
{
|
||||
let mut guard = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
picker.cancel();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Don't take() here — leave the old picker in place so searches
|
||||
// still work until new_with_shared_state replaces it atomically.
|
||||
}
|
||||
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string_lossy().to_string(),
|
||||
false,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
let path = std::path::PathBuf::from(&new_path);
|
||||
if !path.exists() {
|
||||
return Err(LuaError::RuntimeError(format!(
|
||||
"Path does not exist: {}",
|
||||
new_path
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&path).map_err(|e| {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
|
||||
&& picker.base_path() == canonical_path
|
||||
{
|
||||
return Ok(()); // same dir
|
||||
}
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
::tracing::error!(
|
||||
?e,
|
||||
?canonical_path,
|
||||
"Failed to index directory after changing"
|
||||
);
|
||||
} else {
|
||||
::tracing::info!(?canonical_path, "Successfully reindexed directory");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
|
||||
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn fuzzy_search_files(
|
||||
lua: &Lua,
|
||||
(
|
||||
query,
|
||||
max_threads,
|
||||
current_file,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
page_index,
|
||||
page_size,
|
||||
): (
|
||||
String,
|
||||
usize,
|
||||
Option<String>,
|
||||
i32,
|
||||
Option<u32>,
|
||||
Option<usize>,
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let base_path = picker.base_path();
|
||||
let min_combo_count = min_combo_count.unwrap_or(3);
|
||||
|
||||
let 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(&FILE_PICKER, &FRECENCY).into_lua_result()
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
picker
|
||||
.update_single_file_frecency(&file_path, frecency)
|
||||
.into_lua_result()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
picker.stop_background_monitor();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if let Some(picker) = file_picker.take() {
|
||||
drop(picker);
|
||||
::tracing::info!("FilePicker cleanup completed");
|
||||
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
|
||||
// Get the project path before spawning thread
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
// Canonicalize the file path before spawning thread
|
||||
let file_path = match fff_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)
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
|
||||
{
|
||||
tracing::error!(
|
||||
query = %query,
|
||||
file = %file_path.display(),
|
||||
error = ?e,
|
||||
"Failed to track query completion"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tracker
|
||||
.get_historical_query(&project_path, offset)
|
||||
.into_lua_result()
|
||||
}
|
||||
|
||||
pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
|
||||
{
|
||||
tracing::error!(
|
||||
query = %query,
|
||||
error = ?e,
|
||||
"Failed to track grep query"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tracker
|
||||
.get_historical_grep_query(&project_path, offset)
|
||||
.into_lua_result()
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
// Extract the scan signal Arc WITHOUT holding the read lock, so the
|
||||
// scan thread can acquire the write lock to store its results.
|
||||
// Holding a read lock while polling would deadlock: the scan thread
|
||||
// needs a write lock to finish, but can't acquire it while we hold the read lock.
|
||||
let scan_signal = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
picker.scan_signal()
|
||||
}; // read lock released here
|
||||
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
std::thread::sleep(sleep_duration);
|
||||
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
::tracing::debug!(
|
||||
"wait_for_initial_scan completed in {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn init_tracing(
|
||||
_: &Lua,
|
||||
(log_file_path, log_level): (String, Option<String>),
|
||||
) -> LuaResult<String> {
|
||||
crate::log::init_tracing(&log_file_path, log_level.as_deref())
|
||||
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
|
||||
}
|
||||
|
||||
/// Returns health check information including version, git2 status, and repository detection
|
||||
pub fn health_check(lua: &Lua, test_path: Option<String>) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("version", env!("CARGO_PKG_VERSION"))?;
|
||||
|
||||
let test_path = test_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
|
||||
let git_info = lua.create_table()?;
|
||||
let git_version = git2::Version::get();
|
||||
let (major, minor, rev) = git_version.libgit2_version();
|
||||
let libgit2_version_str = format!("{}.{}.{}", major, minor, rev);
|
||||
|
||||
match git2::Repository::discover(&test_path) {
|
||||
Ok(repo) => {
|
||||
git_info.set("available", true)?;
|
||||
git_info.set("repository_found", true)?;
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
git_info.set("workdir", workdir.to_string_lossy().to_string())?;
|
||||
}
|
||||
// Get git2 version info
|
||||
git_info.set("libgit2_version", libgit2_version_str.clone())?;
|
||||
}
|
||||
Err(e) => {
|
||||
git_info.set("available", true)?;
|
||||
git_info.set("repository_found", false)?;
|
||||
git_info.set("error", e.message().to_string())?;
|
||||
git_info.set("libgit2_version", libgit2_version_str)?;
|
||||
}
|
||||
}
|
||||
table.set("git", git_info)?;
|
||||
|
||||
// Check file picker status
|
||||
let picker_info = lua.create_table()?;
|
||||
match FILE_PICKER.read() {
|
||||
Ok(guard) => {
|
||||
if let Some(ref picker) = *guard {
|
||||
picker_info.set("initialized", true)?;
|
||||
picker_info.set(
|
||||
"base_path",
|
||||
picker.base_path().to_string_lossy().to_string(),
|
||||
)?;
|
||||
picker_info.set("is_scanning", picker.is_scan_active())?;
|
||||
let progress = picker.get_scan_progress();
|
||||
picker_info.set("indexed_files", progress.scanned_files_count)?;
|
||||
} else {
|
||||
picker_info.set("initialized", false)?;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
picker_info.set("initialized", false)?;
|
||||
picker_info.set("error", "Failed to acquire file picker lock")?;
|
||||
}
|
||||
}
|
||||
table.set("file_picker", picker_info)?;
|
||||
|
||||
let frecency_info = lua.create_table()?;
|
||||
match FRECENCY.read() {
|
||||
Ok(guard) => {
|
||||
frecency_info.set("initialized", guard.is_some())?;
|
||||
|
||||
if let Some(ref frecency) = *guard {
|
||||
match frecency.get_health() {
|
||||
Ok(health) => {
|
||||
let healthcheck_table = lua.create_table()?;
|
||||
healthcheck_table.set("path", health.path)?;
|
||||
healthcheck_table.set("disk_size", health.disk_size)?;
|
||||
for (name, count) in health.entry_counts {
|
||||
healthcheck_table.set(name, count)?;
|
||||
}
|
||||
frecency_info.set("db_healthcheck", healthcheck_table)?;
|
||||
}
|
||||
Err(e) => {
|
||||
frecency_info.set("db_healthcheck_error", e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
frecency_info.set("initialized", false)?;
|
||||
frecency_info.set("error", "Failed to acquire frecency lock")?;
|
||||
}
|
||||
}
|
||||
table.set("frecency", frecency_info)?;
|
||||
|
||||
let query_tracker_info = lua.create_table()?;
|
||||
match QUERY_TRACKER.read() {
|
||||
Ok(guard) => {
|
||||
query_tracker_info.set("initialized", guard.is_some())?;
|
||||
if let Some(ref query_history) = *guard {
|
||||
match query_history.get_health() {
|
||||
Ok(health) => {
|
||||
let healthcheck_table = lua.create_table()?;
|
||||
healthcheck_table.set("path", health.path)?;
|
||||
healthcheck_table.set("disk_size", health.disk_size)?;
|
||||
for (name, count) in health.entry_counts {
|
||||
healthcheck_table.set(name, count)?;
|
||||
}
|
||||
query_tracker_info.set("db_healthcheck", healthcheck_table)?;
|
||||
}
|
||||
Err(e) => {
|
||||
query_tracker_info.set("db_healthcheck_error", e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
query_tracker_info.set("initialized", false)?;
|
||||
query_tracker_info.set("error", "Failed to acquire query tracker lock")?;
|
||||
}
|
||||
}
|
||||
table.set("query_tracker", query_tracker_info)?;
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
pub fn shorten_path(
|
||||
_: &Lua,
|
||||
(path, max_size, strategy): (String, usize, Option<mlua::Value>),
|
||||
) -> LuaResult<String> {
|
||||
let strategy = strategy
|
||||
.map(|v| -> LuaResult<PathShortenStrategy> {
|
||||
match v {
|
||||
mlua::Value::String(ref s) => {
|
||||
let name = s
|
||||
.to_str()
|
||||
.map(|s| s.to_owned())
|
||||
.unwrap_or_else(|_| "middle_number".to_string());
|
||||
Ok(PathShortenStrategy::from_name(&name))
|
||||
}
|
||||
_ => Ok(PathShortenStrategy::default()),
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
shorten_path_with_cache(strategy, max_size, Path::new(&path)).map_err(LuaError::RuntimeError)
|
||||
}
|
||||
|
||||
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
let exports = lua.create_table()?;
|
||||
exports.set("init_db", lua.create_function(init_db)?)?;
|
||||
exports.set(
|
||||
"destroy_frecency_db",
|
||||
lua.create_function(destroy_frecency_db)?,
|
||||
)?;
|
||||
exports.set("init_file_picker", lua.create_function(init_file_picker)?)?;
|
||||
exports.set(
|
||||
"restart_index_in_path",
|
||||
lua.create_function(restart_index_in_path)?,
|
||||
)?;
|
||||
exports.set("scan_files", lua.create_function(scan_files)?)?;
|
||||
exports.set(
|
||||
"fuzzy_search_files",
|
||||
lua.create_function(fuzzy_search_files)?,
|
||||
)?;
|
||||
exports.set("live_grep", lua.create_function(live_grep)?)?;
|
||||
exports.set("track_access", lua.create_function(track_access)?)?;
|
||||
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
|
||||
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
|
||||
exports.set(
|
||||
"refresh_git_status",
|
||||
lua.create_function(refresh_git_status)?,
|
||||
)?;
|
||||
exports.set("get_git_root", lua.create_function(get_git_root)?)?;
|
||||
exports.set(
|
||||
"stop_background_monitor",
|
||||
lua.create_function(stop_background_monitor)?,
|
||||
)?;
|
||||
exports.set("init_tracing", lua.create_function(init_tracing)?)?;
|
||||
exports.set(
|
||||
"wait_for_initial_scan",
|
||||
lua.create_function(wait_for_initial_scan)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"cleanup_file_picker",
|
||||
lua.create_function(cleanup_file_picker)?,
|
||||
)?;
|
||||
exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?;
|
||||
exports.set(
|
||||
"track_query_completion",
|
||||
lua.create_function(track_query_completion)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"get_historical_query",
|
||||
lua.create_function(get_historical_query)?,
|
||||
)?;
|
||||
exports.set("track_grep_query", lua.create_function(track_grep_query)?)?;
|
||||
exports.set(
|
||||
"get_historical_grep_query",
|
||||
lua.create_function(get_historical_grep_query)?,
|
||||
)?;
|
||||
exports.set("health_check", lua.create_function(health_check)?)?;
|
||||
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true) // creates a new file on every setup
|
||||
.open(log_path)?;
|
||||
|
||||
let level = match log_level
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
Some("debug") => tracing::Level::DEBUG,
|
||||
Some("info") => tracing::Level::INFO,
|
||||
Some("warn") => tracing::Level::WARN,
|
||||
Some("error") => tracing::Level::ERROR,
|
||||
_ => tracing::Level::INFO,
|
||||
};
|
||||
|
||||
TRACING_INITIALIZED.get_or_init(|| {
|
||||
let (non_blocking_appender, guard) = non_blocking(file_appender);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF.nvim tracing initialized with log file: {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
}
|
||||
@@ -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.matches.len())?;
|
||||
table.set("total_files_searched", self.inner.total_files_searched)?;
|
||||
table.set("total_files", self.inner.total_files)?;
|
||||
table.set("filtered_file_count", self.inner.filtered_file_count)?;
|
||||
table.set("next_file_offset", self.inner.next_file_offset)?;
|
||||
|
||||
// Pass regex fallback error to Lua (nil if no error)
|
||||
if let Some(ref err) = self.inner.regex_fallback_error {
|
||||
table.set("regex_fallback_error", err.as_str())?;
|
||||
}
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "fff-query-parser"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["dep:zlob"]
|
||||
|
||||
[dependencies]
|
||||
smallvec = { workspace = true }
|
||||
zlob = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "parse_bench"
|
||||
harness = false
|
||||
@@ -0,0 +1,180 @@
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
|
||||
use fff_query_parser::*;
|
||||
|
||||
fn bench_parse_simple(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_simple_text", |b| {
|
||||
b.iter(|| parser.parse(black_box("hello world")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_text_with_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("name *.rs")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_complex(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_complex_mixed", |b| {
|
||||
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_multiple_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_realistic_queries(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let queries = vec![
|
||||
"file",
|
||||
"test",
|
||||
"mod.rs",
|
||||
"src/*.rs",
|
||||
"lib test",
|
||||
"*.rs !test",
|
||||
"src/lib/*.rs",
|
||||
"/src/ name",
|
||||
"status:modified *.rs",
|
||||
"type:rust test !node_modules",
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("realistic_queries");
|
||||
for query in queries.iter() {
|
||||
group.throughput(Throughput::Bytes(query.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_parse_various_lengths(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let short = "*.rs";
|
||||
let medium = "src name *.rs !test";
|
||||
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
|
||||
let very_long =
|
||||
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
|
||||
|
||||
let mut group = c.benchmark_group("query_lengths");
|
||||
|
||||
group.throughput(Throughput::Bytes(short.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(medium.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(long.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(very_long.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("very_long", very_long.len()),
|
||||
&very_long,
|
||||
|b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_config_comparison(c: &mut Criterion) {
|
||||
let file_picker = QueryParser::new(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);
|
||||
@@ -0,0 +1,109 @@
|
||||
use crate::constraints::Constraint;
|
||||
use crate::glob_detect::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)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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]>;
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Glob wildcard detection — delegates to zlob when available, pure-Rust fallback otherwise.
|
||||
//!
|
||||
//! All call sites use a single function: `has_wildcards(text) -> bool`.
|
||||
//! When the `zlob` feature is enabled this calls `zlob::has_wildcards` with
|
||||
//! `ZlobFlags::RECOMMENDED`; without it we check for the same set of wildcard
|
||||
//! characters (`*`, `?`, `[`, `{`) in pure Rust.
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
zlob::has_wildcards(s, zlob::ZlobFlags::RECOMMENDED)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 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 glob_detect;
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! 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),
|
||||
Range { start: (i32, i32), end: (i32, i32) },
|
||||
Position { line: i32, col: i32 },
|
||||
}
|
||||
|
||||
fn parse_number_pair(location: &str, split_char: char) -> Option<(i32, i32)> {
|
||||
let mut iter = location.split(split_char);
|
||||
|
||||
let start_str = iter.next()?;
|
||||
let end_str = iter.next()?;
|
||||
|
||||
// if there are more than 2 parts it's not the range treat as normal query
|
||||
if iter.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start = start_str.parse::<i32>().ok()?;
|
||||
let end = end_str.parse::<i32>().ok()?;
|
||||
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Parse "line-line" format
|
||||
fn parse_simple_range(location: &str) -> Option<Location> {
|
||||
let (start, end) = parse_number_pair(location, '-')?;
|
||||
if end < start {
|
||||
return Some(Location::Line(start));
|
||||
}
|
||||
|
||||
Some(Location::Range {
|
||||
start: (start, 0),
|
||||
end: (end, 0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse "line:col-col" format (column range on same line)
|
||||
fn parse_column_range(start_part: &str, end_part: &str) -> Option<Location> {
|
||||
let (line_str, start_col_str) = start_part.split_once(':')?;
|
||||
let line = line_str.parse::<i32>().ok()?;
|
||||
let start_col = start_col_str.parse::<i32>().ok()?;
|
||||
let end_col = end_part.parse::<i32>().ok()?;
|
||||
|
||||
if end_col < start_col {
|
||||
return Some(Location::Line(line));
|
||||
}
|
||||
|
||||
Some(Location::Range {
|
||||
start: (line, start_col),
|
||||
end: (line, end_col),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse "line:col-line:col" format (position range)
|
||||
fn parse_position_range(start_part: &str, end_part: &str) -> Option<Location> {
|
||||
let (start_line, start_col) = parse_number_pair(start_part, ':')?;
|
||||
let (end_line, end_col) = parse_number_pair(end_part, ':')?;
|
||||
|
||||
if end_line < start_line || (end_line == start_line && end_col < start_col) {
|
||||
return Some(Location::Position {
|
||||
line: start_line,
|
||||
col: start_col,
|
||||
});
|
||||
}
|
||||
|
||||
Some(Location::Range {
|
||||
start: (start_line, start_col),
|
||||
end: (end_line, end_col),
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to parse range patterns (contains '-')
|
||||
fn try_parse_column_range(location: &str) -> Option<Location> {
|
||||
if !location.contains('-') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (start_part, end_part) = location.split_once('-')?;
|
||||
|
||||
// Try position range (line:col-line:col)
|
||||
if start_part.contains(':') && end_part.contains(':') {
|
||||
return parse_position_range(start_part, end_part);
|
||||
}
|
||||
|
||||
// Try column range (line:col-col)
|
||||
if start_part.contains(':') {
|
||||
return parse_column_range(start_part, end_part);
|
||||
}
|
||||
|
||||
// Try simple line range (line-line)
|
||||
parse_simple_range(location)
|
||||
}
|
||||
|
||||
/// Try to parse position patterns (contains ':' but not '-')
|
||||
fn try_parse_column_position(location: &str) -> Option<Location> {
|
||||
if !location.contains(':') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (line_str, col_str) = location.split_once(':')?;
|
||||
let line = line_str.parse::<i32>().ok()?;
|
||||
let col = col_str.parse::<i32>().ok()?;
|
||||
|
||||
Some(Location::Position { line, col })
|
||||
}
|
||||
|
||||
/// Parses various location formats like file:12, file:12:4, file:12-114
|
||||
fn parse_column_location(query: &str) -> Option<(&str, Location)> {
|
||||
let (file_path, location_part) = query.split_once(':')?;
|
||||
|
||||
if let Some(range_location) = try_parse_column_range(location_part) {
|
||||
return Some((file_path, range_location));
|
||||
}
|
||||
|
||||
if let Some(position_location) = try_parse_column_position(location_part) {
|
||||
return Some((file_path, position_location));
|
||||
}
|
||||
|
||||
if let Ok(line_location) = location_part.parse::<i32>() {
|
||||
return Some((file_path, Location::Line(line_location)));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_vstudio_location(query: &str) -> Option<(&str, Location)> {
|
||||
if !query.ends_with(')') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (file_path, location_with_paren) = query.rsplit_once('(')?;
|
||||
let location = location_with_paren.trim_end_matches(')');
|
||||
|
||||
if let Ok(line) = location.parse::<i32>() {
|
||||
return Some((file_path, Location::Line(line)));
|
||||
}
|
||||
|
||||
if let Some((line, col)) = parse_number_pair(location, ',') {
|
||||
return Some((file_path, Location::Position { line, col }));
|
||||
}
|
||||
|
||||
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([':', '-', '(']);
|
||||
if let Some((path, location)) = parse_column_location(query) {
|
||||
return (path, Some(location));
|
||||
}
|
||||
|
||||
if let Some((path, location)) = parse_vstudio_location(query) {
|
||||
return (path, Some(location));
|
||||
}
|
||||
|
||||
(query, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_location_parsing() {
|
||||
assert_eq!(
|
||||
parse_location("new_file:12"),
|
||||
("new_file", Some(Location::Line(12)))
|
||||
);
|
||||
assert_eq!(parse_location("new_file:12ab"), ("new_file:12ab", None));
|
||||
|
||||
assert_eq!(parse_location("something"), ("something", None));
|
||||
assert_eq!(
|
||||
parse_location("file:12:4"),
|
||||
("file", Some(Location::Position { line: 12, col: 4 }))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:12-114"),
|
||||
(
|
||||
"file",
|
||||
Some(Location::Range {
|
||||
start: (12, 0),
|
||||
end: (114, 0)
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:12:4-20"),
|
||||
(
|
||||
"file",
|
||||
Some(Location::Range {
|
||||
start: (12, 4),
|
||||
end: (12, 20)
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:100:4-14:20"),
|
||||
("file", Some(Location::Position { line: 100, col: 4 }))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:12:4-14:20"),
|
||||
(
|
||||
"file",
|
||||
Some(Location::Range {
|
||||
start: (12, 4),
|
||||
end: (14, 20)
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vstudio_parsing() {
|
||||
assert_eq!(
|
||||
parse_location("file(12)"),
|
||||
("file", Some(Location::Line(12)))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_location("file(12,4)"),
|
||||
("file", Some(Location::Position { line: 12, col: 4 }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimes_end_character() {
|
||||
assert_eq!(
|
||||
parse_location("file:12-"),
|
||||
("file", Some(Location::Line(12)))
|
||||
);
|
||||
assert_eq!(parse_location("file:-"), ("file", None));
|
||||
assert_eq!(parse_location("file("), ("file", None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
use crate::ConstraintVec;
|
||||
use crate::config::ParserConfig;
|
||||
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
|
||||
use crate::glob_detect::has_wildcards;
|
||||
use crate::location::{Location, parse_location};
|
||||
|
||||
#[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;
|
||||
}
|
||||
|
||||
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<'a> FFFQuery<'a> {
|
||||
/// Returns the grep search text by joining all non-constraint text tokens.
|
||||
///
|
||||
/// Backslash-escaped tokens (e.g. `\*.rs`) are included as literal text
|
||||
/// with the leading `\` stripped, since the backslash is only an escape
|
||||
/// signal to the parser and should not appear in the final pattern.
|
||||
///
|
||||
/// `FuzzyQuery::Empty` → empty string
|
||||
/// `FuzzyQuery::Text("foo")` → `"foo"`
|
||||
/// `FuzzyQuery::Parts(["a", "\\*.rs", "b"])` → `"a *.rs b"`
|
||||
pub fn grep_text(&self) -> String {
|
||||
match &self.fuzzy_query {
|
||||
FuzzyQuery::Empty => String::new(),
|
||||
FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
|
||||
FuzzyQuery::Parts(parts) => parts
|
||||
.iter()
|
||||
.map(|t| strip_leading_backslash(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the leading `\` from a backslash-escaped token, returning the rest.
|
||||
/// For all other tokens returns the input unchanged.
|
||||
#[inline]
|
||||
fn strip_leading_backslash(token: &str) -> &str {
|
||||
if token.starts_with('\\') && token.len() > 1 {
|
||||
&token[1..]
|
||||
} else {
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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, GrepConfig};
|
||||
|
||||
#[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_text_plain_text() {
|
||||
// Multi-token plain text — no constraints
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name =")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name =");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_strips_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name = *.rs someth")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name = someth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_leading_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("*.rs name =")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name =");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_only_constraints() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("*.rs /src/")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_path_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name /src/ value")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_negation_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name !*.rs value")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_backslash_escape_stripped() {
|
||||
// \*.rs should be text with the leading \ removed
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\*.rs foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "*.rs foo");
|
||||
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\/src/ foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "/src/ foo");
|
||||
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\!test foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "!test foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_question_mark_is_text() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("foo? bar")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "foo? bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_bracket_is_text() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("arr[0] more")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "arr[0] more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_path_glob_is_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("pattern src/**/*.rs")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_question_mark_is_text() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use grep_matcher::{LineTerminator, Match, Matcher};
|
||||
|
||||
use crate::{
|
||||
searcher::glue::{MultiLine, SliceByLine},
|
||||
sink::{Sink, SinkError},
|
||||
};
|
||||
|
||||
mod core;
|
||||
mod glue;
|
||||
|
||||
/// We use this type alias since we want the ergonomics of a matcher's `Match`
|
||||
/// type, but in practice, we use it for arbitrary ranges, so give it a more
|
||||
/// accurate name. This is only used in the searcher's internals.
|
||||
type Range = Match;
|
||||
|
||||
/// An error that can occur when building a searcher.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub(crate) enum ConfigError {
|
||||
/// Occurs when a matcher reports a line terminator that is different than
|
||||
/// the one configured in the searcher.
|
||||
MismatchedLineTerminators {
|
||||
/// The matcher's line terminator.
|
||||
matcher: LineTerminator,
|
||||
/// The searcher's line terminator.
|
||||
searcher: LineTerminator,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
ConfigError::MismatchedLineTerminators { matcher, searcher } => {
|
||||
write!(
|
||||
f,
|
||||
"grep config error: mismatched line terminators, \
|
||||
matcher has {:?} but searcher has {:?}",
|
||||
matcher, searcher
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The internal configuration of a searcher.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Config {
|
||||
/// The line terminator to use.
|
||||
pub(crate) line_term: LineTerminator,
|
||||
/// Whether to count line numbers.
|
||||
pub(crate) line_number: bool,
|
||||
/// Whether to enable matching across multiple lines.
|
||||
multi_line: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Config {
|
||||
Config {
|
||||
line_term: LineTerminator::default(),
|
||||
line_number: true,
|
||||
multi_line: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A builder for configuring a searcher.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SearcherBuilder {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Default for SearcherBuilder {
|
||||
fn default() -> SearcherBuilder {
|
||||
SearcherBuilder::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SearcherBuilder {
|
||||
/// Create a new searcher builder with a default configuration.
|
||||
pub fn new() -> SearcherBuilder {
|
||||
SearcherBuilder {
|
||||
config: Config::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a searcher.
|
||||
pub fn build(&self) -> Searcher {
|
||||
Searcher {
|
||||
config: self.config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to count and include line numbers with matching lines.
|
||||
pub fn line_number(&mut self, yes: bool) -> &mut SearcherBuilder {
|
||||
self.config.line_number = yes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to enable multi line search or not.
|
||||
pub fn multi_line(&mut self, yes: bool) -> &mut SearcherBuilder {
|
||||
self.config.multi_line = yes;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A searcher executes searches over a haystack and writes results to a caller
|
||||
/// provided sink.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Searcher {
|
||||
pub(crate) config: Config,
|
||||
}
|
||||
|
||||
impl Searcher {
|
||||
/// Create a new searcher with a default configuration.
|
||||
pub fn new() -> Searcher {
|
||||
SearcherBuilder::new().build()
|
||||
}
|
||||
|
||||
/// Execute a search over the given slice and write the results to the
|
||||
/// given sink.
|
||||
pub fn search_slice<M, S>(&self, matcher: M, slice: &[u8], write_to: S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Matcher,
|
||||
S: Sink,
|
||||
{
|
||||
self.check_config(&matcher)
|
||||
.map_err(S::Error::error_message)?;
|
||||
|
||||
if self.multi_line_with_matcher(&matcher) {
|
||||
MultiLine::new(self, matcher, slice, write_to).run()
|
||||
} else {
|
||||
SliceByLine::new(self, matcher, slice, write_to).run()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that the searcher's configuration and the matcher are consistent.
|
||||
fn check_config<M: Matcher>(&self, matcher: M) -> Result<(), ConfigError> {
|
||||
let matcher_line_term = match matcher.line_terminator() {
|
||||
None => return Ok(()),
|
||||
Some(line_term) => line_term,
|
||||
};
|
||||
if matcher_line_term != self.config.line_term {
|
||||
return Err(ConfigError::MismatchedLineTerminators {
|
||||
matcher: matcher_line_term,
|
||||
searcher: self.config.line_term,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Searcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration query methods used by the sink and internal search core.
|
||||
impl Searcher {
|
||||
/// Returns the line terminator used by this searcher.
|
||||
#[inline]
|
||||
pub fn line_terminator(&self) -> LineTerminator {
|
||||
self.config.line_term
|
||||
}
|
||||
|
||||
/// Returns true if and only if this searcher is configured to count line
|
||||
/// numbers.
|
||||
#[inline]
|
||||
pub fn line_number(&self) -> bool {
|
||||
self.config.line_number
|
||||
}
|
||||
|
||||
/// Returns true if and only if this searcher is configured to perform
|
||||
/// multi line search.
|
||||
#[inline]
|
||||
pub fn multi_line(&self) -> bool {
|
||||
self.config.multi_line
|
||||
}
|
||||
|
||||
/// Returns true if and only if this searcher will choose a multi-line
|
||||
/// strategy given the provided matcher.
|
||||
pub fn multi_line_with_matcher<M: Matcher>(&self, matcher: M) -> bool {
|
||||
if !self.multi_line() {
|
||||
return false;
|
||||
}
|
||||
if let Some(line_term) = matcher.line_terminator()
|
||||
&& line_term == self.line_terminator()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(non_matching) = matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.line_terminator().as_byte())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+448
-126
@@ -1,17 +1,15 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.8.0 Last change: 2025 August 02
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 06
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
- Features |fff.nvim-features|
|
||||
- Installation |fff.nvim-installation|
|
||||
- Configuration |fff.nvim-configuration|
|
||||
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 we’ll try to
|
||||
solve file picking completely.
|
||||
|
||||
@@ -24,16 +22,17 @@ FEATURES *fff.nvim-features*
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- Typo resistant fuzzy search <https://github.com/saghen/frizbee>
|
||||
- Git status integration allowing to take adavantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicaged backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
|
||||
|
||||
INSTALLATION *fff.nvim-installation*
|
||||
|
||||
|
||||
[!NOTE] Although we’ll try to make sure to keep 100% backward compatibiility,
|
||||
[!NOTE] Although we’ll try to make sure to keep 100% backward compatibility,
|
||||
by using you should understand that silly bugs and breaking changes may happen.
|
||||
And also we hope for your contributions and feedback to make this plugin ideal
|
||||
for everyone.
|
||||
@@ -43,105 +42,277 @@ PREREQUISITES ~
|
||||
FFF.nvim requires:
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- Rust toolchain (requires nightly for building the native backend)
|
||||
- Rustup <https://rustup.rs/> (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
|
||||
|
||||
PACKAGE MANAGERS ~
|
||||
INSTALLATION ~
|
||||
|
||||
|
||||
LAZY.NVIM
|
||||
|
||||
>lua
|
||||
{
|
||||
"dmtrKovalenko/fff.nvim",
|
||||
build = "cargo build --release",
|
||||
opts = {
|
||||
-- pass here all the options
|
||||
},
|
||||
keys = {
|
||||
{
|
||||
"<leader>ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function()
|
||||
require("fff").toggle()
|
||||
end,
|
||||
desc = "Toggle FFF",
|
||||
'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,
|
||||
-- if you are using nixos
|
||||
-- build = "nix run .#release",
|
||||
opts = { -- (optional)
|
||||
debug = {
|
||||
enabled = true, -- we expect your collaboration at least during the beta
|
||||
show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
|
||||
},
|
||||
},
|
||||
-- No need to lazy-load with lazy.nvim.
|
||||
-- This plugin initializes itself lazily.
|
||||
lazy = false,
|
||||
keys = {
|
||||
{
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
},
|
||||
{
|
||||
"fg",
|
||||
function() require('fff').live_grep() end,
|
||||
desc = 'LiFFFe grep',
|
||||
},
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
<
|
||||
|
||||
|
||||
DEFAULT CONFIGURATION ~
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here’s the complete default
|
||||
configuration:
|
||||
VIM.PACK
|
||||
|
||||
>lua
|
||||
require("fff").setup({
|
||||
-- UI dimensions and appearance
|
||||
width = 0.8, -- Window width as fraction of screen
|
||||
height = 0.8, -- Window height as fraction of screen
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
preview = {
|
||||
enabled = true,
|
||||
width = 0.5,
|
||||
max_lines = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
|
||||
|
||||
-- Key mappings (supports both single keys and arrays for multiple bindings)
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
|
||||
-- Highlight groups
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
|
||||
-- Debug options
|
||||
debug = {
|
||||
show_scores = false, -- Toggle with F2 or :FFFDebug
|
||||
},
|
||||
vim.api.nvim_create_autocmd('PackChanged', {
|
||||
callback = function(event)
|
||||
if event.data.updated then
|
||||
require('fff.download').download_or_build_binary()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- the plugin will automatically lazy load
|
||||
vim.g.fff = {
|
||||
lazy_sync = true, -- start syncing only when the picker is open
|
||||
debug = {
|
||||
enabled = true,
|
||||
show_scores = true,
|
||||
},
|
||||
}
|
||||
|
||||
vim.keymap.set(
|
||||
'n',
|
||||
'ff',
|
||||
function() require('fff').find_files() end,
|
||||
{ desc = 'FFFind files' }
|
||||
)
|
||||
<
|
||||
|
||||
|
||||
CONFIGURATION ~
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here’s the complete configuration with
|
||||
all available options:
|
||||
|
||||
>lua
|
||||
require('fff').setup({
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '🪿 ',
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
|
||||
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
},
|
||||
})
|
||||
<
|
||||
|
||||
|
||||
KEY FEATURES ~
|
||||
|
||||
|
||||
MULTIPLE KEY BINDINGS
|
||||
|
||||
You can assign multiple key combinations to the same action:
|
||||
AVAILABLE METHODS
|
||||
|
||||
>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
|
||||
}
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
<
|
||||
|
||||
|
||||
COMMANDS
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
- `:FFFHealth` - Check FFF health status and dependencies
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
|
||||
MULTILINE PASTE SUPPORT
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining
|
||||
@@ -157,65 +328,216 @@ Toggle scoring information display:
|
||||
- Use `:FFFDebug` command
|
||||
- Enable by default with `debug.show_scores = true`
|
||||
|
||||
>
|
||||
|
||||
#### vim-plug
|
||||
|
||||
```vim
|
||||
Plug 'MunifTanjim/nui.nvim'
|
||||
Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' }
|
||||
<
|
||||
|
||||
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
|
||||
|
||||
|
||||
CONFIGURATION *fff.nvim-configuration*
|
||||
LIVE GREP SEARCH MODES
|
||||
|
||||
Live grep supports three search modes, cycled with `<S-Tab>`:
|
||||
|
||||
DEFAULT CONFIGURATION ~
|
||||
- **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.
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here’s the complete default
|
||||
configuration:
|
||||
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({
|
||||
-- UI dimensions and appearance
|
||||
width = 0.8, -- Window width as fraction of screen
|
||||
height = 0.8, -- Window height as fraction of screen
|
||||
preview_width = 0.5, -- Preview pane width as fraction of picker
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
|
||||
debug = {
|
||||
show_scores = true, -- We hope for your collaboratio
|
||||
},
|
||||
require('fff').setup({
|
||||
grep = {
|
||||
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
**Per-call configuration:**
|
||||
|
||||
>lua
|
||||
-- Only fuzzy and plain modes for this specific grep
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Single mode (hides mode indicator completely)
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Pre-fill the search with an initial query
|
||||
require('fff').live_grep({ query = 'search term' })
|
||||
<
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and
|
||||
the cycle keybind does nothing.
|
||||
|
||||
|
||||
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
|
||||
|
||||
Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This
|
||||
will verify:
|
||||
|
||||
- File picker initialization status
|
||||
- Optional dependencies (git, image preview tools)
|
||||
- Database connectivity
|
||||
|
||||
|
||||
VIEWING LOGS
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
>vim
|
||||
:FFFOpenLog
|
||||
<
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
|
||||
location).
|
||||
|
||||
|
||||
COMMON ISSUES
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it’s properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
|
||||
DEBUG MODE
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
|
||||
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
|
||||
|
||||
vim:tw=78:ts=8:noet:ft=help:norl:
|
||||
|
||||
@@ -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
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1767744144,
|
||||
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770865833,
|
||||
"narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
description = "fff.nvim";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
crane.url = "github:ipetkov/crane";
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
crane,
|
||||
flake-utils,
|
||||
rust-overlay,
|
||||
...
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./crates/fff-nvim/Cargo.toml);
|
||||
|
||||
# Common arguments can be set here to avoid repeating them later
|
||||
# Note: changes here will rebuild all dependency crates
|
||||
commonArgs = {
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
strictDeps = true;
|
||||
|
||||
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl 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
|
||||
copy-dynamic-library = /* bash */ ''
|
||||
set -eo pipefail
|
||||
mkdir -p target/release
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
cp -vf ${my-crate}/lib/libfff_nvim.dylib target/release/libfff_nvim.dylib
|
||||
else
|
||||
cp -vf ${my-crate}/lib/libfff_nvim.so target/release/libfff_nvim.so
|
||||
fi
|
||||
echo "Library copied to target/release/"
|
||||
'';
|
||||
in
|
||||
{
|
||||
checks = {
|
||||
inherit my-crate;
|
||||
};
|
||||
|
||||
packages = {
|
||||
default = my-crate;
|
||||
|
||||
# Neovim plugin
|
||||
fff-nvim = pkgs.vimUtils.buildVimPlugin {
|
||||
pname = "fff.nvim";
|
||||
version = "main";
|
||||
src = pkgs.lib.cleanSource ./.;
|
||||
postPatch = copy-dynamic-library;
|
||||
doCheck = false; # Skip require check since we have a Rust FFI component
|
||||
};
|
||||
};
|
||||
|
||||
apps.default = flake-utils.lib.mkApp {
|
||||
drv = my-crate;
|
||||
};
|
||||
|
||||
# Add the release command
|
||||
apps.release = flake-utils.lib.mkApp {
|
||||
drv = pkgs.writeShellScriptBin "release" copy-dynamic-library;
|
||||
};
|
||||
|
||||
devShells.default = craneLib.devShell {
|
||||
# Inherit inputs from checks.
|
||||
checks = self.checks.${system};
|
||||
# Extra inputs can be added here; cargo and rustc are provided by default.
|
||||
packages = [
|
||||
# pkgs.ripgrep
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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
|
||||
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_set_extmark(buf, ns_id, line_idx - 1, 0, { end_row = line_idx, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
LEFT_HEADER_PADDING,
|
||||
{ end_col = LEFT_HEADER_PADDING + text_len, hl_group = config.hl.combo_header }
|
||||
)
|
||||
end
|
||||
|
||||
local function get_or_create_overlay_buf(state_key)
|
||||
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = overlay_state[state_key] })
|
||||
end
|
||||
return overlay_state[state_key]
|
||||
end
|
||||
|
||||
local function update_overlay_content(buf, content, border_hl)
|
||||
-- Batch all buffer operations together for performance
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
|
||||
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
|
||||
vim.api.nvim_buf_set_extmark(buf, overlay_state.ns_id, 0, 0, { end_row = 1, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
|
||||
end
|
||||
|
||||
local function position_overlay_window(state_key, buf, width, row, col)
|
||||
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
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
|
||||
end
|
||||
|
||||
vim.api.nvim_set_option_value('winhighlight', 'Normal:Normal', { win = overlay_state[state_key] })
|
||||
end
|
||||
|
||||
local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
local list_config = vim.api.nvim_win_get_config(list_win)
|
||||
-- combo_header_line is a 1-based buffer line index (includes any padding offset)
|
||||
-- list_config.row is where the window starts (0-based, at the top border)
|
||||
-- Content starts at list_config.row + 1 (after the top border)
|
||||
-- Buffer line 1 -> screen row (list_config.row + 1)
|
||||
-- Buffer line N -> screen row (list_config.row + N)
|
||||
-- Since combo_header_line is 1-based, the formula naturally works out
|
||||
local combo_header_row = list_config.row + combo_header_line
|
||||
|
||||
-- Skip update if position and highlight haven't changed
|
||||
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
|
||||
@@ -0,0 +1,365 @@
|
||||
local M = {}
|
||||
|
||||
--- @class FffLayoutConfig
|
||||
--- @field height number
|
||||
--- @field width number
|
||||
--- @field prompt_position string
|
||||
--- @field preview_position string
|
||||
--- @field preview_size number
|
||||
--- @field show_scrollbar boolean
|
||||
--- @field path_shorten_strategy string
|
||||
|
||||
--- @class FffPreviewConfig
|
||||
--- @field enabled boolean
|
||||
--- @field max_size number
|
||||
--- @field chunk_size number
|
||||
--- @field binary_file_threshold number
|
||||
--- @field imagemagick_info_format_str string
|
||||
--- @field line_numbers boolean
|
||||
--- @field cursorlineopt string
|
||||
--- @field wrap_lines boolean
|
||||
--- @field filetypes table<string, table>
|
||||
|
||||
--- @class FffKeymapsConfig
|
||||
--- @field close string
|
||||
--- @field select string
|
||||
--- @field select_split string
|
||||
--- @field select_vsplit string
|
||||
--- @field select_tab string
|
||||
--- @field move_up string|string[]
|
||||
--- @field move_down string|string[]
|
||||
--- @field preview_scroll_up string
|
||||
--- @field preview_scroll_down string
|
||||
--- @field toggle_debug string
|
||||
--- @field cycle_grep_modes string
|
||||
--- @field cycle_previous_query string
|
||||
--- @field toggle_select string
|
||||
--- @field send_to_quickfix string
|
||||
--- @field focus_list string
|
||||
--- @field focus_preview string
|
||||
|
||||
--- @class FffFrecencyConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
|
||||
--- @class FffHistoryConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
--- @field min_combo_count number
|
||||
--- @field combo_boost_score_multiplier number
|
||||
|
||||
--- @class FffGrepConfig
|
||||
--- @field max_file_size number
|
||||
--- @field max_matches_per_file number
|
||||
--- @field smart_case boolean
|
||||
--- @field time_budget_ms number
|
||||
--- @field modes string[]
|
||||
|
||||
--- @class FffConfig
|
||||
--- @field base_path string
|
||||
--- @field prompt string
|
||||
--- @field title string
|
||||
--- @field max_results number
|
||||
--- @field max_threads number
|
||||
--- @field lazy_sync boolean
|
||||
--- @field layout FffLayoutConfig
|
||||
--- @field preview FffPreviewConfig
|
||||
--- @field keymaps FffKeymapsConfig
|
||||
--- @field hl table<string, string>
|
||||
--- @field frecency FffFrecencyConfig
|
||||
--- @field history FffHistoryConfig
|
||||
--- @field git table
|
||||
--- @field debug table
|
||||
--- @field logging table
|
||||
--- @field file_picker table
|
||||
--- @field grep FffGrepConfig
|
||||
|
||||
---@class fff.conf.State
|
||||
local state = {
|
||||
---@type FffConfig|nil
|
||||
config = nil,
|
||||
}
|
||||
|
||||
local DEPRECATION_RULES = {
|
||||
{
|
||||
-- Top-level width -> layout.width
|
||||
old_path = { 'width' },
|
||||
new_path = { 'layout', 'width' },
|
||||
message = 'config.width is deprecated. Use config.layout.width instead.',
|
||||
},
|
||||
{
|
||||
-- Top-level height -> layout.height
|
||||
old_path = { 'height' },
|
||||
new_path = { 'layout', 'height' },
|
||||
message = 'config.height is deprecated. Use config.layout.height instead.',
|
||||
},
|
||||
{
|
||||
-- preview.width -> layout.preview_size
|
||||
old_path = { 'preview', 'width' },
|
||||
new_path = { 'layout', 'preview_size' },
|
||||
message = 'config.preview.width is deprecated. Use config.layout.preview_size instead.',
|
||||
},
|
||||
{
|
||||
-- layout.preview_width -> layout.preview_size
|
||||
old_path = { 'layout', 'preview_width' },
|
||||
new_path = { 'layout', 'preview_size' },
|
||||
message = 'config.layout.preview_width is deprecated. Use config.layout.preview_size instead.',
|
||||
},
|
||||
}
|
||||
|
||||
--- Get value from nested table using path array
|
||||
--- @param tbl table Source table
|
||||
--- @param path table Array of keys to traverse
|
||||
--- @return any|nil Value at path or nil if not found
|
||||
local function get_nested_value(tbl, path)
|
||||
local current = tbl
|
||||
for _, key in ipairs(path) do
|
||||
if type(current) ~= 'table' or current[key] == nil then return nil end
|
||||
current = current[key]
|
||||
end
|
||||
|
||||
return current
|
||||
end
|
||||
|
||||
--- Set value in nested table using path array, creating intermediate tables
|
||||
--- @param tbl table Target table
|
||||
--- @param path table Array of keys to traverse
|
||||
--- @param value any Value to set
|
||||
local function set_nested_value(tbl, path, value)
|
||||
local current = tbl
|
||||
for i = 1, #path - 1 do
|
||||
local key = path[i]
|
||||
if type(current[key]) ~= 'table' then current[key] = {} end
|
||||
current = current[key]
|
||||
end
|
||||
|
||||
current[path[#path]] = value
|
||||
end
|
||||
|
||||
--- Remove value from nested table using path array
|
||||
--- @param tbl table Target table
|
||||
--- @param path table Array of keys to traverse
|
||||
local function remove_nested_value(tbl, path)
|
||||
if #path == 0 then return end
|
||||
|
||||
local current = tbl
|
||||
for i = 1, #path - 1 do
|
||||
local key = path[i]
|
||||
if type(current[key]) ~= 'table' then return end
|
||||
current = current[key]
|
||||
end
|
||||
|
||||
current[path[#path]] = nil
|
||||
end
|
||||
|
||||
--- Handle deprecated configuration options with migration warnings
|
||||
--- @param user_config table User provided configuration
|
||||
--- @return table Migrated configuration
|
||||
local function handle_deprecated_config(user_config)
|
||||
if not user_config then return {} end
|
||||
|
||||
local migrated_config = vim.deepcopy(user_config)
|
||||
|
||||
for _, rule in ipairs(DEPRECATION_RULES) do
|
||||
local old_value = get_nested_value(user_config, rule.old_path)
|
||||
if old_value ~= nil then
|
||||
set_nested_value(migrated_config, rule.new_path, old_value)
|
||||
remove_nested_value(migrated_config, rule.old_path)
|
||||
|
||||
vim.notify('FFF: ' .. rule.message, vim.log.levels.WARN)
|
||||
end
|
||||
end
|
||||
|
||||
return migrated_config
|
||||
end
|
||||
|
||||
---@param name table list of highlight groups to choose from
|
||||
---@return string one of the provided groups
|
||||
local function fallback_hl(name)
|
||||
local resolved_hl
|
||||
for _, hl in ipairs(name) do
|
||||
local resolved_group = vim.api.nvim_get_hl(0, { name = hl })
|
||||
|
||||
if not vim.tbl_isempty(resolved_group) then resolved_hl = hl end
|
||||
end
|
||||
|
||||
return resolved_hl or name[#name]
|
||||
end
|
||||
|
||||
local function init()
|
||||
local config = vim.g.fff or {}
|
||||
local default_config = {
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '🪿 ',
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to nil to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
|
||||
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both',
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
},
|
||||
}
|
||||
|
||||
local migrated_user_config = handle_deprecated_config(config)
|
||||
local merged_config = vim.tbl_deep_extend('force', default_config, migrated_user_config)
|
||||
|
||||
state.config = merged_config
|
||||
end
|
||||
|
||||
--- Setup the file picker with the given configuration
|
||||
--- @param config FffConfig Configuration options
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- @return FffConfig the fff configuration
|
||||
function M.get()
|
||||
if not state.config then init() end
|
||||
return state.config
|
||||
end
|
||||
|
||||
--- @return boolean state_changed
|
||||
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
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,108 @@
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class fff.core.State
|
||||
local state = {
|
||||
---@type boolean
|
||||
initialized = false,
|
||||
---@type boolean
|
||||
file_picker_initialized = false,
|
||||
}
|
||||
|
||||
---@param config table
|
||||
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({ 'BufEnter' }, {
|
||||
group = group,
|
||||
desc = 'Track file access for FFF frecency',
|
||||
callback = function(args)
|
||||
local file_path = args.file
|
||||
if not (file_path and file_path ~= '' and not vim.startswith(file_path, 'term://')) then return end
|
||||
|
||||
vim.uv.fs_stat(file_path, function(err, stat)
|
||||
if err or not stat then return end
|
||||
|
||||
vim.uv.fs_realpath(file_path, function(rp_err, real_path)
|
||||
if rp_err or not real_path then return end
|
||||
local ok, track_err = pcall(fuzzy.track_access, real_path)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(
|
||||
function() vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR) end
|
||||
)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- make sure that this won't work correctly if autochdir plugins are enabled
|
||||
-- using a pure :cd command but will work using lua api or :e command
|
||||
vim.api.nvim_create_autocmd('DirChanged', {
|
||||
group = group,
|
||||
callback = function()
|
||||
if vim.v.event.scope == 'window' then return end
|
||||
local new_cwd = vim.v.event.cwd
|
||||
if state.initialized and new_cwd and new_cwd ~= config.base_path then
|
||||
vim.schedule(function()
|
||||
-- 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)
|
||||
end
|
||||
end,
|
||||
desc = 'Automatically sync FFF directory changes',
|
||||
})
|
||||
end
|
||||
|
||||
--- @return boolean
|
||||
M.is_file_picker_initialized = function() return state.file_picker_initialized end
|
||||
|
||||
M.ensure_initialized = function()
|
||||
if state.initialized then return fuzzy end
|
||||
state.initialized = true
|
||||
|
||||
local config = require('fff.conf').get()
|
||||
if config.logging.enabled then
|
||||
local log_success, log_error = pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level)
|
||||
if log_success then
|
||||
M.log_file_path = log_error
|
||||
else
|
||||
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
|
||||
end
|
||||
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
|
||||
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
|
||||
return fuzzy
|
||||
end
|
||||
|
||||
state.file_picker_initialized = true
|
||||
setup_global_autocmds(config)
|
||||
|
||||
local git_utils = require('fff.git_utils')
|
||||
git_utils.setup_highlights()
|
||||
|
||||
return fuzzy
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,280 @@
|
||||
local M = {}
|
||||
local system = require('fff.utils.system')
|
||||
local fs_utils = require('fff.utils.fs')
|
||||
|
||||
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
|
||||
|
||||
local function get_current_version(plugin_dir, callback)
|
||||
vim.system({ 'git', 'rev-parse', '--short', 'HEAD' }, { cwd = plugin_dir }, function(result)
|
||||
if result.code ~= 0 or not result.stdout or result.stdout == '' then
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
callback(result.stdout:gsub('%s+', ''))
|
||||
end)
|
||||
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)
|
||||
local extension = system.get_lib_extension()
|
||||
return binary_dir .. '/libfff_nvim.' .. extension
|
||||
end
|
||||
|
||||
local function binary_exists(plugin_dir)
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
local stat = vim.uv.fs_stat(binary_path)
|
||||
if stat and stat.type == 'file' then return true end
|
||||
|
||||
-- On Windows the rename over a loaded DLL fails, so a verified binary may be
|
||||
-- left at binary_path .. '.tmp'. Promote it now that the old session is gone.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
local tmp_stat = vim.uv.fs_stat(tmp_path)
|
||||
if tmp_stat and tmp_stat.type == 'file' then
|
||||
-- Verify the .tmp is a valid library before promoting it, in case the
|
||||
-- process was killed between the loadlib check and the rename attempt
|
||||
-- during a previous download, leaving a corrupt or partial .tmp on disk.
|
||||
local loader = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
return false
|
||||
end
|
||||
local ok = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
return ok ~= nil
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function download_file(url, output_path, opts, callback)
|
||||
opts = opts or {}
|
||||
|
||||
local dir = vim.fn.fnamemodify(output_path, ':h')
|
||||
fs_utils.mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
|
||||
if not mkdir_ok then
|
||||
callback(false, mkdir_err)
|
||||
return
|
||||
end
|
||||
|
||||
local curl_args = {
|
||||
'curl',
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--output',
|
||||
output_path,
|
||||
}
|
||||
|
||||
if opts.proxy then
|
||||
table.insert(curl_args, '--proxy')
|
||||
table.insert(curl_args, opts.proxy)
|
||||
end
|
||||
|
||||
if opts.extra_curl_args then
|
||||
for _, arg in ipairs(opts.extra_curl_args) do
|
||||
table.insert(curl_args, arg)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(curl_args, url)
|
||||
vim.system(curl_args, {}, function(result)
|
||||
if result.code ~= 0 then
|
||||
callback(false, 'Failed to download: ' .. (result.stderr or 'unknown error'))
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function download_from_github(version, binary_path, opts, callback)
|
||||
opts = opts or {}
|
||||
|
||||
local triple = system.get_triple()
|
||||
local extension = system.get_lib_extension()
|
||||
local binary_name = triple .. '.' .. extension
|
||||
local url = string.format('https://github.com/%s/releases/download/%s/%s', GITHUB_REPO, version, binary_name)
|
||||
|
||||
vim.schedule(function()
|
||||
vim.notify(string.format('Downloading fff.nvim binary for ' .. version), vim.log.levels.INFO)
|
||||
vim.notify(string.format('Do not open fff until you see a success notification.'), vim.log.levels.WARN)
|
||||
end)
|
||||
|
||||
-- Download to a temp path first so we can validate before replacing the live binary.
|
||||
-- If we wrote directly to binary_path and the current process already has the old
|
||||
-- library loaded, package.loadlib() on the same path returns the *cached* handle —
|
||||
-- meaning a truncated download would pass validation silently.
|
||||
-- Using a distinct temp path forces dlopen to load the new file for real.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
|
||||
download_file(url, tmp_path, {
|
||||
proxy = opts.proxy,
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
vim.uv.fanoushkas_unlink(tmp_path)
|
||||
callback(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
-- Validate the downloaded binary by actually loading it (temp path is not yet
|
||||
-- loaded by this process, so dlopen loads the new file for real and catches
|
||||
-- truncated or corrupt downloads).
|
||||
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing.
|
||||
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
-- Atomically replace the live binary only after successful validation.
|
||||
-- On Windows the old .dll may be locked by the current process, so rename can
|
||||
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
|
||||
-- so the next Neovim start can pick it up automatically.
|
||||
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
if not rename_ok then
|
||||
if vim.uv.os_uname().sysname:lower():match('windows') then
|
||||
vim.notify(
|
||||
'fff.nvim binary downloaded to '
|
||||
.. tmp_path
|
||||
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
callback(true, nil)
|
||||
else
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.ensure_downloaded(opts, callback)
|
||||
opts = opts or {}
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
|
||||
if binary_exists(plugin_dir) and not opts.force then
|
||||
callback(true, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local function on_version(target_version)
|
||||
if not target_version then
|
||||
callback(false, 'Could not determine target version')
|
||||
return
|
||||
end
|
||||
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
download_from_github(target_version, binary_path, opts, callback)
|
||||
end
|
||||
|
||||
if opts.version then
|
||||
on_version(opts.version)
|
||||
else
|
||||
get_current_version(plugin_dir, on_version)
|
||||
end
|
||||
end
|
||||
|
||||
function M.download_binary(callback)
|
||||
M.ensure_downloaded({ force = true }, function(success, err)
|
||||
if not success then
|
||||
if callback then
|
||||
callback(false, err)
|
||||
else
|
||||
vim.schedule(
|
||||
function()
|
||||
vim.notify('Failed to download fff.nvim binary: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
|
||||
end
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
if callback then callback(true, nil) end
|
||||
end)
|
||||
end
|
||||
|
||||
function M.build_binary(callback)
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
local has_rustup = vim.fn.executable('rustup') == 1
|
||||
if not has_rustup then
|
||||
callback(
|
||||
false,
|
||||
'rustup is not found. It is required to build the fff.nvim binary. Install it from https://rustup.rs/'
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
vim.system({ 'cargo', 'build', '--release' }, { cwd = plugin_dir }, function(result)
|
||||
if result.code ~= 0 then
|
||||
callback(false, 'Failed to build rust binary: ' .. (result.stderr or 'unknown error'))
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.download_or_build_binary()
|
||||
local done = false
|
||||
local fatal_error = nil
|
||||
|
||||
M.ensure_downloaded({ force = true }, function(download_success, download_error)
|
||||
if download_success then
|
||||
done = true
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(
|
||||
function()
|
||||
vim.notify(
|
||||
'Error downloading binary: ' .. (download_error or 'unknown error') .. '\nTrying cargo build --release\n',
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
)
|
||||
|
||||
M.build_binary(function(build_success, build_error)
|
||||
if not build_success then
|
||||
fatal_error = 'Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error')
|
||||
else
|
||||
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
|
||||
end
|
||||
done = true
|
||||
end)
|
||||
end)
|
||||
|
||||
-- Block the caller (and keep the Neovim event loop alive) until the entire
|
||||
-- download-or-build chain finishes. This is critical for lazy.nvim build
|
||||
-- hooks: lazy returns from the hook immediately after this function returns,
|
||||
-- and if Neovim exits before the final rename(tmp → libfff_nvim.{dylib,so,dll})
|
||||
-- executes, the binary is never written to disk. vim.wait pumps the event
|
||||
-- loop so all vim.system / vim.schedule callbacks can fire.
|
||||
local timeout_ms = 1000 * 60 * 2 -- 2 minutes
|
||||
local ok, wait_err = vim.wait(timeout_ms, function() return done end, 100)
|
||||
if not ok and wait_err == -2 then error('fff.nvim: download_or_build_binary timed out') end
|
||||
|
||||
if fatal_error then error(fatal_error) end
|
||||
end
|
||||
|
||||
function M.get_binary_path()
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
return get_binary_path(plugin_dir)
|
||||
end
|
||||
|
||||
function M.get_binary_cpath_component()
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
local binary_dir = get_binary_dir(plugin_dir)
|
||||
local extension = system.get_lib_extension()
|
||||
return binary_dir .. '/lib?.' .. extension
|
||||
end
|
||||
|
||||
return M
|
||||
+20
-153
@@ -7,49 +7,14 @@ local icon_providers = {
|
||||
|
||||
M.provider = nil
|
||||
M.provider_name = nil
|
||||
|
||||
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 = {}
|
||||
M.setup_attempted = false
|
||||
M.setup_failed = false
|
||||
|
||||
function M.setup()
|
||||
if M.provider_name then return true end
|
||||
if M.setup_failed then return false end
|
||||
|
||||
M.setup_attempted = true
|
||||
|
||||
for _, provider_name in ipairs(icon_providers) do
|
||||
local ok, provider = pcall(require, provider_name)
|
||||
@@ -60,150 +25,52 @@ function M.setup()
|
||||
end
|
||||
end
|
||||
|
||||
vim.notify('FFF Icons: No icon provider found. Please install nvim-web-devicons or mini.icons', vim.log.levels.WARN)
|
||||
M.setup_failed = true
|
||||
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, _ = 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
|
||||
|
||||
+193
-110
@@ -1,29 +1,94 @@
|
||||
--- Image handling module for file picker
|
||||
--- Simple implementation that delegates to Snacks.nvim
|
||||
local utils = require('fff.utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Track active image placements per buffer
|
||||
local active_placements = {} ---@type table<number, any>
|
||||
|
||||
-- Helper function to safely set buffer lines
|
||||
local function safe_set_buffer_lines(bufnr, start, end_line, strict_indexing, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return false end
|
||||
|
||||
-- Make buffer modifiable temporarily
|
||||
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
|
||||
-- Set the lines
|
||||
local ok, err = pcall(vim.api.nvim_buf_set_lines, bufnr, start, end_line, strict_indexing, lines)
|
||||
|
||||
-- Restore modifiable state
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
|
||||
|
||||
return ok
|
||||
local function get_main_config()
|
||||
local main = require('fff.main')
|
||||
return main.config
|
||||
end
|
||||
|
||||
local active_placements = {} ---@type table<number, any>
|
||||
local loading_jobs = {} ---@type table<number, {metadata_job?: any}>
|
||||
|
||||
local function reserve_image_buffer_space(bufnr, metadata_lines_count)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
local win = vim.fn.bufwinid(bufnr)
|
||||
local buffer_height = win ~= -1 and vim.api.nvim_win_get_height(win) or 24
|
||||
local lines_for_image = math.max(buffer_height - metadata_lines_count - 2, 5)
|
||||
|
||||
local buffer_lines = {}
|
||||
for _ = 1, metadata_lines_count do
|
||||
table.insert(buffer_lines, '')
|
||||
end
|
||||
for _ = 1, lines_for_image do
|
||||
table.insert(buffer_lines, '')
|
||||
end
|
||||
|
||||
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
|
||||
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
|
||||
|
||||
return metadata_lines_count or 2
|
||||
end
|
||||
|
||||
local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
reserved_lines_count = reserved_lines_count or 2
|
||||
|
||||
local metadata_lines = {}
|
||||
for i = 1, reserved_lines_count do
|
||||
metadata_lines[i] = info_lines[i] or ''
|
||||
end
|
||||
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function identify_image_lines_async(file_path, bufnr, callback)
|
||||
local stat = vim.uv.fs_stat(file_path)
|
||||
local size_str = stat and utils.format_file_size(stat.size) or 'Unknown'
|
||||
|
||||
local initial_info_lines = { ' Size: ' .. size_str }
|
||||
|
||||
if vim.fn.executable('identify') == 0 then
|
||||
callback(initial_info_lines)
|
||||
return
|
||||
end
|
||||
|
||||
callback(initial_info_lines)
|
||||
|
||||
local config = get_main_config()
|
||||
local format_str = config and config.preview and config.preview.imagemagick_info_format_str
|
||||
or '%m: %wx%h, %[colorspace], %q-bit'
|
||||
|
||||
local cmd = { 'identify', '-format', format_str, file_path }
|
||||
|
||||
-- Cancel any previous metadata job
|
||||
if loading_jobs[bufnr] and loading_jobs[bufnr].metadata_job then
|
||||
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
|
||||
end
|
||||
loading_jobs[bufnr] = loading_jobs[bufnr] or {}
|
||||
|
||||
loading_jobs[bufnr].metadata_job = vim.system(cmd, { text = true }, function(result)
|
||||
vim.schedule(function()
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
local enhanced_info_lines = vim.deepcopy(initial_info_lines)
|
||||
if result.code == 0 and result.stdout and result.stdout ~= '' then
|
||||
local magick_info = ' ' .. result.stdout:gsub('\n', '')
|
||||
table.insert(enhanced_info_lines, magick_info)
|
||||
end
|
||||
|
||||
callback(enhanced_info_lines)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Common image extensions (SVG excluded for text preview)
|
||||
local IMAGE_EXTENSIONS = {
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
@@ -41,7 +106,6 @@ local IMAGE_EXTENSIONS = {
|
||||
'.avif',
|
||||
}
|
||||
|
||||
--- Check if file is an image
|
||||
--- @param file_path string Path to the file
|
||||
--- @return boolean True if file is an image
|
||||
function M.is_image(file_path)
|
||||
@@ -55,127 +119,146 @@ function M.is_image(file_path)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if we're in Kitty terminal
|
||||
--- @return boolean True if in Kitty
|
||||
function M.is_kitty() return vim.env.KITTY_PID ~= nil end
|
||||
|
||||
--- Clear any existing image attachments from buffer
|
||||
--- @param bufnr number Buffer number
|
||||
function M.clear_buffer_images(bufnr)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
-- Close any tracked placement for this buffer
|
||||
if loading_jobs[bufnr] then
|
||||
if loading_jobs[bufnr].metadata_job then
|
||||
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
|
||||
end
|
||||
loading_jobs[bufnr] = nil
|
||||
end
|
||||
|
||||
if active_placements[bufnr] then
|
||||
pcall(active_placements[bufnr].close, active_placements[bufnr])
|
||||
active_placements[bufnr] = nil
|
||||
end
|
||||
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if ok and snacks.image and snacks.image.placement then
|
||||
-- Use the proper Snacks.nvim placement cleanup API
|
||||
-- This removes all image placements for the specified buffer
|
||||
pcall(snacks.image.placement.clean, bufnr)
|
||||
end
|
||||
if ok and snacks.image and snacks.image.placement then pcall(snacks.image.placement.clean, bufnr) end
|
||||
|
||||
pcall(vim.api.nvim_buf_clear_namespace, bufnr, -1, 0, -1)
|
||||
end
|
||||
|
||||
--- Display image using the simplest approach that works
|
||||
--- Load metadata of the image, displays it and display image in paralallel
|
||||
--- Fully asynchronous
|
||||
--- @param file_path string Path to the image file
|
||||
--- @param bufnr number Buffer number to display in
|
||||
--- @param max_width number Maximum width in characters
|
||||
--- @param max_height number Maximum height in characters
|
||||
function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
max_width = max_width or 80
|
||||
max_height = max_height or 24
|
||||
|
||||
if not M.is_kitty() then
|
||||
M.display_image_info(file_path, bufnr, 'Not in Kitty terminal')
|
||||
return
|
||||
--- @return boolean
|
||||
function M.display_image(file_path, bufnr)
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
for _, win in ipairs(wins) do
|
||||
vim.api.nvim_set_option_value('number', false, { win = win })
|
||||
end
|
||||
|
||||
-- Try Snacks.nvim first (most reliable)
|
||||
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
|
||||
local image_content_starts_at_line = reserved_metadata_lines + 1
|
||||
identify_image_lines_async(
|
||||
file_path,
|
||||
bufnr,
|
||||
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
|
||||
)
|
||||
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if ok and snacks.image and snacks.image.buf then
|
||||
-- Clear any existing image attachments first
|
||||
if not ok then
|
||||
local error_lines = {
|
||||
'⚠ Image Preview Unavailable',
|
||||
'',
|
||||
'snacks.nvim plugin is not installed or not available.',
|
||||
}
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
if not snacks.image.supports_terminal() then
|
||||
local error_lines = {
|
||||
'⚠ Image Preview Unavailable',
|
||||
'',
|
||||
'Terminal does not support image preview.',
|
||||
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
|
||||
}
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
if not snacks.image.supports_file(file_path) then
|
||||
local error_lines = {
|
||||
'⚠ Unsupported Image Format',
|
||||
'',
|
||||
'File format is not supported for image preview.',
|
||||
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
|
||||
}
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
if snacks.image and snacks.image.placement then
|
||||
M.clear_buffer_images(bufnr)
|
||||
|
||||
-- Clear buffer content to prevent text/image overlap
|
||||
safe_set_buffer_lines(bufnr, 0, -1, false, {})
|
||||
vim.schedule(function()
|
||||
local success, placement = pcall(snacks.image.placement.new, bufnr, file_path, {
|
||||
pos = { image_content_starts_at_line, 1 },
|
||||
inline = true,
|
||||
fit = 'contain',
|
||||
auto_resize = true,
|
||||
})
|
||||
|
||||
-- Configure Snacks image to prevent repetition
|
||||
local success, placement = pcall(snacks.image.buf.attach, bufnr, {
|
||||
src = file_path,
|
||||
fit = 'contain', -- Fit image within bounds without repetition
|
||||
})
|
||||
if success and placement then active_placements[bufnr] = placement end
|
||||
end)
|
||||
|
||||
if success and placement then
|
||||
-- Track the placement so we can clean it up later
|
||||
active_placements[bufnr] = placement
|
||||
return -- Successfully attached image, we're done
|
||||
else
|
||||
M.display_image_info(file_path, bufnr, 'Snacks.nvim failed: ' .. tostring(placement or 'unknown error'))
|
||||
return
|
||||
end
|
||||
identify_image_lines_async(
|
||||
file_path,
|
||||
bufnr,
|
||||
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
|
||||
)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
M.display_image_info(file_path, bufnr, 'Snacks.nvim not available')
|
||||
return false
|
||||
end
|
||||
|
||||
--- Display image information when image display fails
|
||||
--- @param file_path string Path to the image file
|
||||
--- @param bufnr number Buffer number to display in
|
||||
--- @param reason string|nil Reason for failure
|
||||
function M.display_image_info(file_path, bufnr, reason)
|
||||
local info = {}
|
||||
--- 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 stat = vim.loop.fs_stat(file_path)
|
||||
if stat then
|
||||
table.insert(info, string.format('📁 File: %s', vim.fn.fnamemodify(file_path, ':t')))
|
||||
table.insert(info, string.format('📏 Size: %d bytes', stat.size))
|
||||
table.insert(info, string.format('🕒 Modified: %s', os.date('%Y-%m-%d %H:%M:%S', stat.mtime.sec)))
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if not ok then
|
||||
status.error = 'snacks.nvim not installed'
|
||||
return status
|
||||
end
|
||||
|
||||
local width, height = M.get_image_dimensions(file_path)
|
||||
if width and height then table.insert(info, string.format('🖼️ Dimensions: %dx%d pixels', width, height)) end
|
||||
status.snacks_available = true
|
||||
|
||||
local ext = string.lower(vim.fn.fnamemodify(file_path, ':e'))
|
||||
if ext ~= '' then table.insert(info, string.format('🎨 Format: %s', ext:upper())) end
|
||||
|
||||
table.insert(info, '')
|
||||
table.insert(info, '┌─ Image Preview Debug ─┐')
|
||||
table.insert(info, string.format('│ Kitty: %s │', M.is_kitty() and 'Yes' or 'No'))
|
||||
table.insert(info, string.format('│ KITTY_PID: %s │', vim.env.KITTY_PID or 'nil'))
|
||||
if reason then
|
||||
table.insert(info, string.format('│ Issue: %s', reason:sub(1, 16)))
|
||||
if #reason > 16 then table.insert(info, string.format('│ %s', reason:sub(17, 32))) end
|
||||
end
|
||||
table.insert(info, '└─────────────────────────┘')
|
||||
|
||||
safe_set_buffer_lines(bufnr, 0, -1, false, info)
|
||||
end
|
||||
|
||||
--- Get image dimensions using file command
|
||||
--- @param file_path string Path to the image file
|
||||
--- @return number|nil, number|nil Width and height in pixels
|
||||
function M.get_image_dimensions(file_path)
|
||||
-- Try file command first
|
||||
local cmd = string.format('file "%s"', file_path)
|
||||
local result = vim.fn.system(cmd)
|
||||
|
||||
if vim.v.shell_error == 0 then
|
||||
local width, height = result:match('(%d+)%s*x%s*(%d+)')
|
||||
if width and height then return tonumber(width), tonumber(height) end
|
||||
if not snacks.image then
|
||||
status.error = 'snacks.image module not available'
|
||||
return status
|
||||
end
|
||||
|
||||
-- Fallback to identify command (ImageMagick)
|
||||
cmd = string.format('identify -format "%%w %%h" "%s" 2>/dev/null', file_path)
|
||||
result = vim.fn.system(cmd)
|
||||
status.snacks_image_available = true
|
||||
|
||||
if vim.v.shell_error == 0 then
|
||||
local width, height = result:match('(%d+)%s+(%d+)')
|
||||
if width and height then return tonumber(width), tonumber(height) end
|
||||
if not snacks.image.supports_terminal or not snacks.image.supports_terminal() then
|
||||
status.error = 'terminal does not support image display'
|
||||
return status
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
status.terminal_supported = true
|
||||
status.available = true
|
||||
|
||||
return status
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -2,68 +2,18 @@
|
||||
--- Uses advanced fuzzy search algorithm with frecency scoring
|
||||
|
||||
local M = {}
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
|
||||
-- Load the fuzzy module for file operations
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
|
||||
-- State
|
||||
M.state = {
|
||||
initialized = false,
|
||||
base_path = nil,
|
||||
last_scan_time = 0,
|
||||
config = nil,
|
||||
}
|
||||
|
||||
--- Initialize the file picker
|
||||
--- @param config table Configuration for the file picker
|
||||
function M.setup(config)
|
||||
config = config or {}
|
||||
|
||||
-- Default configuration
|
||||
local defaults = {
|
||||
base_path = vim.fn.getcwd(),
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
show_hidden = false,
|
||||
ignore_patterns = {},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_lines = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
keymaps = {
|
||||
select = '<CR>',
|
||||
vsplit = '<C-v>',
|
||||
split = '<C-s>',
|
||||
tab = '<C-t>',
|
||||
close = '<Esc>',
|
||||
preview_up = '<C-u>',
|
||||
preview_down = '<C-d>',
|
||||
},
|
||||
layout = {
|
||||
prompt_position = 'top',
|
||||
preview_position = 'right',
|
||||
preview_width = 0.4,
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
},
|
||||
}
|
||||
|
||||
M.config = vim.tbl_deep_extend('force', defaults, config)
|
||||
M.state.config = M.config
|
||||
|
||||
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
|
||||
|
||||
ok, result = pcall(fuzzy.init_file_picker, M.config.base_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
function M.setup()
|
||||
local config = require('fff.conf').get()
|
||||
M.state.initialized = true
|
||||
M.state.base_path = M.config.base_path
|
||||
M.state.base_path = config.base_path
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -81,39 +31,61 @@ function M.scan_files()
|
||||
M.state.last_scan_time = os.time()
|
||||
end
|
||||
|
||||
--- Get cached files from the file picker
|
||||
function M.get_cached_files()
|
||||
if not M.state.initialized then return {} end
|
||||
|
||||
local ok, files = pcall(fuzzy.get_cached_files)
|
||||
if not ok then
|
||||
vim.notify('Failed to get cached files: ' .. files, vim.log.levels.ERROR)
|
||||
return {}
|
||||
end
|
||||
|
||||
return files
|
||||
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
|
||||
--- Results are always returned in descending order (best scores first)
|
||||
--- @param query string Search query
|
||||
--- @param max_results number|nil Maximum number of results (optional)
|
||||
--- @param max_threads number|nil Maximum number of threads (optional)
|
||||
--- @param current_file string|nil Path to current file to deprioritize (optional)
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @return table List of matching files
|
||||
function M.search_files(query, current_file, max_results, max_threads, 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 fuzzy matching using blink.cmp's advanced algorithm
|
||||
--- Search files with pagination support
|
||||
--- 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 current_file string|nil Path to current file to deprioritize (optional)
|
||||
--- @param max_threads number|nil Maximum number of threads to use
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
|
||||
--- @param page_size number|nil Items per page (nil uses config default)
|
||||
--- @return table List of matching files
|
||||
function M.search_files(query, max_results, max_threads, current_file)
|
||||
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 M.config.max_results
|
||||
max_threads = max_threads or M.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)
|
||||
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
|
||||
|
||||
@@ -127,33 +99,28 @@ function M.get_search_metadata()
|
||||
}
|
||||
end
|
||||
|
||||
--- Get location data from the last search result
|
||||
--- @return table|nil Location data if available
|
||||
function M.get_search_location()
|
||||
if not M.state.last_search_result then return nil end
|
||||
return M.state.last_search_result.location
|
||||
end
|
||||
|
||||
--- Get score information for a file by index (1-based)
|
||||
--- @param index number The index of the file in the last search results
|
||||
--- @return table|nil Score information or nil if not available
|
||||
function M.get_file_score(index)
|
||||
if not M.state.last_search_result or not M.state.last_search_result.scores then return nil end
|
||||
|
||||
-- Convert to 0-based index for Lua table access
|
||||
local score = M.state.last_search_result.scores[index]
|
||||
if not score then return nil end
|
||||
|
||||
return {
|
||||
total = score.total or 0,
|
||||
base_score = score.base_score or 0,
|
||||
filename_bonus = score.filename_bonus or 0,
|
||||
special_filename_bonus = score.special_filename_bonus or 0,
|
||||
frecency_boost = score.frecency_boost or 0,
|
||||
distance_penalty = score.distance_penalty or 0,
|
||||
match_type = score.match_type or 'unknown',
|
||||
}
|
||||
return M.state.last_search_result.scores[index]
|
||||
end
|
||||
|
||||
--- Record file access for frecency tracking
|
||||
--- @param file_path string Path to the file that was accessed
|
||||
function M.access_file(file_path)
|
||||
function M.track_access(file_path)
|
||||
if not M.state.initialized then return end
|
||||
|
||||
local ok, result = pcall(fuzzy.access_file, file_path)
|
||||
local ok, result = pcall(fuzzy.track_access, file_path)
|
||||
if not ok then vim.notify('Failed to record file access: ' .. result, vim.log.levels.WARN) end
|
||||
end
|
||||
|
||||
@@ -182,20 +149,17 @@ end
|
||||
--- @return boolean
|
||||
function M.is_initialized() return M.state.initialized end
|
||||
|
||||
--- Get current configuration
|
||||
--- @return table
|
||||
function M.get_config() return M.config end
|
||||
|
||||
--- Get scan progress information
|
||||
--- @return table Progress information with total_files, scanned_files, is_scanning
|
||||
--- @return table Progress information with scanned_files_count, is_scanning
|
||||
function M.get_scan_progress()
|
||||
if not M.state.initialized then return { total_files = 0, scanned_files = 0, is_scanning = false } end
|
||||
if not M.state.initialized then return { total_files = 0, scanned_files_count = 0, is_scanning = false } end
|
||||
|
||||
local ok, result = pcall(fuzzy.get_scan_progress)
|
||||
if not ok then
|
||||
vim.notify('Failed to get scan progress: ' .. result, vim.log.levels.WARN)
|
||||
return { total_files = 0, scanned_files = 0, is_scanning = false }
|
||||
return { scanned_files_count = 0, is_scanning = false }
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
|
||||
+630
-481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
--- File Renderer
|
||||
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
|
||||
local M = {}
|
||||
|
||||
--- File Item structure from Rust
|
||||
--- @class FileItem
|
||||
--- @field path string Absolute file path
|
||||
--- @field relative_path string Relative file path from base directory
|
||||
--- @field name string File name
|
||||
--- @field extension string File extension
|
||||
--- @field size number File size in bytes
|
||||
--- @field modified number Last modified timestamp
|
||||
--- @field total_frecency_score number Total frecency score
|
||||
--- @field access_frecency_score number Access-based frecency score
|
||||
--- @field modification_frecency_score number Modification-based frecency score
|
||||
--- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
|
||||
--- internal:
|
||||
--- @field _has_group_header boolean Internal flag for render_line to indicate if this item has a combo header line (not from Rust)
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @return string[] Array of line strings (1 or 2 lines if combo)
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local lines = {}
|
||||
|
||||
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
|
||||
if has_combo then table.insert(lines, ctx.combo_header_line) end
|
||||
|
||||
local icon, _ = icons.get_icon(item.name, item.extension, false)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
if ctx.debug_enabled then
|
||||
local total = item.total_frecency_score or 0
|
||||
local access = item.access_frecency_score or 0
|
||||
local mod = item.modification_frecency_score or 0
|
||||
|
||||
if total > 0 then
|
||||
local indicator = ''
|
||||
if mod >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access >= 4 then
|
||||
indicator = '⭐️'
|
||||
elseif total >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total >= 1 then
|
||||
indicator = '•'
|
||||
end
|
||||
frecency = string.format(' %s%d', indicator, total)
|
||||
end
|
||||
end
|
||||
|
||||
-- Format filename and path
|
||||
-- Don't reserve space for frecency - path takes priority
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- Build line
|
||||
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
|
||||
or string.format('%s %s%s', filename, dir_path, frecency)
|
||||
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
table.insert(lines, line .. string.rep(' ', padding))
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
--- @param line_idx number 1-based line index in buffer
|
||||
--- @param line_content string The actual line content
|
||||
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local file_picker = require('fff.file_picker')
|
||||
|
||||
local is_cursor = (ctx.cursor == item_idx)
|
||||
local score = file_picker.get_file_score(item_idx)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
|
||||
-- Get icon and paths
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- 1. Cursor highlight
|
||||
if is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = line_idx,
|
||||
hl_group = ctx.config.hl.cursor,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- 2. Icon
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_hl = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
0,
|
||||
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
|
||||
)
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
local filename_start = #icon + 1
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
filename_start,
|
||||
{ end_col = filename_start + #filename, hl_group = git_text_hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. Frecency indicator
|
||||
if ctx.debug_enabled then
|
||||
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
|
||||
if start_pos and end_pos then
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
start_pos - 1,
|
||||
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- 5. Directory path (dimmed)
|
||||
if #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #filename + 1 -- filename bytes + space
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
|
||||
)
|
||||
end
|
||||
|
||||
-- 6. Current file
|
||||
if is_current_file then
|
||||
local hl
|
||||
if is_cursor then
|
||||
hl = ctx.config.hl.cursor
|
||||
else
|
||||
hl = 'Comment'
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
-- 7. Git sign
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.cursor
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = ctx.config.hl.cursor,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
-- 8. Selection
|
||||
if ctx.selected_files and ctx.selected_files[item.path] then
|
||||
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001,
|
||||
})
|
||||
end
|
||||
|
||||
-- 9. Query match
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+23
-1
@@ -1,3 +1,4 @@
|
||||
---@class fff.fuzzy
|
||||
local M = {}
|
||||
|
||||
-- Try to load the Rust module
|
||||
@@ -15,18 +16,39 @@ M.get_keyword_range = rust_module.get_keyword_range
|
||||
M.guess_edit_range = rust_module.guess_edit_range
|
||||
M.get_words = rust_module.get_words
|
||||
M.init_file_picker = rust_module.init_file_picker
|
||||
M.restart_index_in_path = rust_module.restart_index_in_path
|
||||
M.scan_files = rust_module.scan_files
|
||||
M.get_cached_files = rust_module.get_cached_files
|
||||
M.fuzzy_search_files = rust_module.fuzzy_search_files
|
||||
M.access_file = rust_module.access_file
|
||||
M.track_access = rust_module.track_access
|
||||
M.add_file = rust_module.add_file
|
||||
M.remove_file = rust_module.remove_file
|
||||
M.cancel_scan = rust_module.cancel_scan
|
||||
M.get_scan_progress = rust_module.get_scan_progress
|
||||
M.is_scanning = rust_module.is_scanning
|
||||
M.refresh_git_status = rust_module.refresh_git_status
|
||||
M.update_single_file_frecency = rust_module.update_single_file_frecency
|
||||
M.stop_background_monitor = rust_module.stop_background_monitor
|
||||
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
|
||||
|
||||
+142
-34
@@ -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,23 +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
|
||||
|
||||
function M.get_highlight(git_status) return M.highlights[git_status] or '' end
|
||||
--- Build and cache highlight mappings from config
|
||||
local function ensure_cache()
|
||||
if highlights_cache then return end
|
||||
|
||||
function M.get_border_highlight(git_status) return M.border_highlights[git_status] or '' end
|
||||
local config = require('fff.conf').get()
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -63,26 +109,88 @@ function M.setup_highlights()
|
||||
vim.cmd([[
|
||||
" Symbol highlights
|
||||
highlight default FFFGitStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
" Thin border highlights
|
||||
|
||||
" Thin border highlights
|
||||
highlight default FFFGitSignStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitSignRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitSignUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
|
||||
" Fallback to GitSigns highlights if they exist
|
||||
highlight default link FFFGitSignStaged GitSignsAdd
|
||||
highlight default link FFFGitSignModified GitSignsChange
|
||||
highlight default link FFFGitSignDeleted GitSignsDelete
|
||||
highlight default link FFFGitSignUntracked GitSignsAdd
|
||||
]])
|
||||
|
||||
-- Highlighes for git signs both for selected and normal states
|
||||
local git_highlights = {
|
||||
{ 'FFFGitSignStaged', 'FFFGitSignStagedSelected', '#10B981', 2 },
|
||||
{ 'FFFGitSignModified', 'FFFGitSignModifiedSelected', '#F59E0B', 3 },
|
||||
{ 'FFFGitSignDeleted', 'FFFGitSignDeletedSelected', '#EF4444', 1 },
|
||||
{ 'FFFGitSignRenamed', 'FFFGitSignRenamedSelected', '#8B5CF6', 5 },
|
||||
{ 'FFFGitSignUntracked', 'FFFGitSignUntrackedSelected', '#10B981', 2 },
|
||||
{ '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 gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
|
||||
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
|
||||
|
||||
vim.cmd(
|
||||
string.format(
|
||||
'highlight default %s guifg=%s guibg=%s ctermfg=%d ctermbg=%s',
|
||||
selected_hl,
|
||||
gui_fg,
|
||||
gui_bg,
|
||||
cterm_fg,
|
||||
cterm_bg
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
--- 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')
|
||||
local tresitter_highlight = require('fff.treesitter_hl')
|
||||
|
||||
--- Build the file group header line using the same layout as file_renderer.
|
||||
--- Delegates to file_renderer.render_line (with combo disabled).
|
||||
---@param item FileItem Grep match
|
||||
---@param ctx table Render context
|
||||
---@return string The header line string
|
||||
local function build_group_header(item, ctx)
|
||||
ctx.has_combo = false
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
local lines = file_renderer.render_line(item, ctx, 0)
|
||||
ctx.has_combo = false -- never has a combo in grep
|
||||
return lines[1]
|
||||
end
|
||||
|
||||
--- Apply highlights for a file group header line using file_renderer.
|
||||
--- Delegates to file_renderer.apply_highlights so all highlight groups
|
||||
--- (icon, filename, git text color, directory path, git sign) match exactly.
|
||||
---@param item FileItem Grep match item
|
||||
---@param ctx ListRenderContext Render context
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
---@param row number 0-based row in buffer (header line)
|
||||
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 content = raw_content
|
||||
|
||||
-- Indent + location + separator + content
|
||||
local indent = ' '
|
||||
local prefix_display_w = #indent + #location + #separator
|
||||
local available = ctx.win_width - prefix_display_w - 2
|
||||
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) .. '…'
|
||||
end
|
||||
|
||||
local line = indent .. location .. separator .. content
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
-- 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 = tresitter_highlight.lang_from_filename(item.name) or false
|
||||
ctx._ts_lang_cache[item.name] = lang
|
||||
end
|
||||
|
||||
if lang then
|
||||
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
|
||||
for _, hl in ipairs(highlights) do
|
||||
local hl_start = content_start + hl.col
|
||||
local hl_end = content_start + hl.end_col
|
||||
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
|
||||
for _, range in ipairs(item.match_ranges) do
|
||||
local raw_start = range[1] or 0
|
||||
local raw_end = range[2] or 0
|
||||
|
||||
if raw_end > 0 then
|
||||
raw_start = math.max(0, raw_start)
|
||||
local hl_start = content_start + raw_start
|
||||
local hl_end = content_start + raw_end
|
||||
if hl_start < #line_content and hl_end <= #line_content then
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
|
||||
end_col = hl_end,
|
||||
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 FileItem Grep match item
|
||||
---@param ctx table Render context
|
||||
---@return string[]
|
||||
function M.render_line(item, ctx)
|
||||
-- Track file grouping across the render pass via ctx
|
||||
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
|
||||
local is_new_group = (item.path ~= ctx._grep_last_file)
|
||||
ctx._grep_last_file = item.path
|
||||
|
||||
local match_line = render_match_line(item, ctx)
|
||||
|
||||
if is_new_group then
|
||||
item._has_group_header = true
|
||||
local header_line = build_group_header(item, ctx)
|
||||
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 FileItem Grep match item
|
||||
---@param ctx ListRenderContext Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
---@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
|
||||
@@ -0,0 +1,55 @@
|
||||
--- 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)
|
||||
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,285 @@
|
||||
--- 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 FffConfig User configuration
|
||||
--- @field items table[] Array of data items to render
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
--- @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
|
||||
--- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files')
|
||||
|
||||
--- @class ItemLineMapping
|
||||
--- @field first number First buffer line (1-based) this item occupies
|
||||
--- @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 table
|
||||
--- @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).
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
local item_lines = renderer.render_line(item, ctx, i)
|
||||
vim.list_extend(lines, item_lines)
|
||||
|
||||
local item_end_line = #lines
|
||||
local virtual_count = item_end_line - item_start_line -- 0 if single line, 1 if header + content
|
||||
|
||||
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 table
|
||||
--- @return number padding_offset Number of empty lines prepended
|
||||
local function apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
if ctx.prompt_position ~= 'bottom' then return 0 end
|
||||
|
||||
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 table
|
||||
--- @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_set_option_value('modifiable', true, { buf = list_buf })
|
||||
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf })
|
||||
|
||||
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 table
|
||||
--- @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 item_lines then
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
if line_content then
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Render the full item list into the buffer.
|
||||
--- This is the main entry point — replaces the inline rendering in picker_ui.
|
||||
---
|
||||
--- @param ctx table Render context built by picker_ui
|
||||
--- @param list_buf number List buffer handle
|
||||
--- @param list_win number List window handle
|
||||
--- @param ns_id number Highlight namespace
|
||||
--- @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_set_extmark,
|
||||
list_buf,
|
||||
ns_id,
|
||||
i,
|
||||
0,
|
||||
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,297 @@
|
||||
local M = {}
|
||||
|
||||
--- Jump to a location in the current buffer
|
||||
--- @param location table|nil Location data from search results
|
||||
function M.jump_to_location(location)
|
||||
if not location then return end
|
||||
|
||||
local current_buf = vim.api.nvim_get_current_buf()
|
||||
local line_count = vim.api.nvim_buf_line_count(current_buf)
|
||||
|
||||
if location.line then
|
||||
local target_line = math.max(1, math.min(location.line, line_count))
|
||||
local target_col = location.col and math.max(0, location.col - 1) or 0
|
||||
|
||||
vim.api.nvim_win_set_cursor(0, { target_line, target_col })
|
||||
vim.cmd('normal! zz')
|
||||
elseif location.start and location['end'] then
|
||||
-- Extract line numbers from nested structure
|
||||
local start_line = math.max(1, math.min(location.start.line, line_count))
|
||||
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
|
||||
|
||||
-- start in the visual mode and selecting the range backwards so the cursor ends up at the start
|
||||
vim.api.nvim_win_set_cursor(0, { end_line, 0 })
|
||||
vim.cmd('normal! V')
|
||||
if end_line > start_line then vim.cmd('normal! ' .. (end_line - start_line) .. 'k') end
|
||||
vim.cmd('normal! zz')
|
||||
end
|
||||
end
|
||||
|
||||
--- Highlight a location range in a buffer using extmarks
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param location table|nil Location data from search results
|
||||
--- @param namespace number Namespace for extmarks
|
||||
--- @return table|nil Highlight extmark details for cleanup
|
||||
function M.highlight_location(bufnr, location, namespace)
|
||||
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return nil end
|
||||
|
||||
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))
|
||||
|
||||
if location.col then
|
||||
local target_col = math.max(0, location.col - 1)
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, target_line - 1, target_line, false)[1] or ''
|
||||
local end_col = math.min(target_col + 1, #line_content)
|
||||
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, target_col, {
|
||||
end_col = end_col,
|
||||
hl_group = 'IncSearch', -- inc search are better visible for a single chars
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
|
||||
else
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
|
||||
line_hl_group = 'Visual',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
|
||||
end
|
||||
elseif location.start and location['end'] then
|
||||
local start_line = math.max(1, math.min(location.start.line, line_count))
|
||||
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
|
||||
|
||||
-- Check if we have column information for exact range highlighting
|
||||
if location.start.col and location['end'].col then
|
||||
if start_line == end_line then
|
||||
-- Single line range with columns: highlight exact character range
|
||||
local start_col = math.max(0, location.start.col - 1)
|
||||
local end_col = location['end'].col - 1
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, start_line, false)[1] or ''
|
||||
end_col = math.min(end_col, #line_content)
|
||||
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, start_line - 1, start_col, {
|
||||
end_col = end_col,
|
||||
hl_group = 'IncSearch',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = start_line - 1 }) end
|
||||
else
|
||||
-- Multi-line range with exact columns: highlight precise ranges
|
||||
for line = start_line, end_line do
|
||||
local line_start_col, line_end_col
|
||||
|
||||
if line == start_line then
|
||||
-- First line: from start_col to end of line
|
||||
line_start_col = math.max(0, location.start.col - 1)
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
|
||||
line_end_col = #line_content
|
||||
elseif line == end_line then
|
||||
-- Last line: from beginning to end_col
|
||||
line_start_col = 0
|
||||
line_end_col = location['end'].col - 1
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
|
||||
line_end_col = math.min(line_end_col, #line_content)
|
||||
else
|
||||
-- Middle lines: entire line
|
||||
line_start_col = 0
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
|
||||
line_end_col = #line_content
|
||||
end
|
||||
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, line_start_col, {
|
||||
end_col = line_end_col,
|
||||
hl_group = 'Visual',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Multi-line or no columns: highlight entire lines
|
||||
for line = start_line, end_line do
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, 0, {
|
||||
line_hl_group = 'Visual',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
function M.clear_location_highlights(bufnr, namespace)
|
||||
if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) end
|
||||
end
|
||||
|
||||
--- Get the target line for scrolling preview to location
|
||||
--- @param location table|nil Location data from search results
|
||||
--- @return number|nil Target line number (1-indexed) for scrolling
|
||||
function M.get_target_line(location)
|
||||
if not location then return nil end
|
||||
|
||||
if location.line then
|
||||
return location.line
|
||||
elseif location.start then
|
||||
return location.start.line
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Check if location is valid for a buffer
|
||||
--- @param location table|nil Location data
|
||||
--- @param bufnr number Buffer number
|
||||
--- @return boolean True if location is valid for the buffer
|
||||
function M.is_valid_location(location, bufnr)
|
||||
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return false end
|
||||
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
|
||||
if location.line then
|
||||
return location.line > 0 and location.line <= line_count
|
||||
elseif location.start and location['end'] then
|
||||
return location.start.line > 0 and location.start.line <= line_count
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Format location for display
|
||||
--- @param location table|nil Location data
|
||||
--- @return string Formatted location string
|
||||
function M.format_location(location)
|
||||
if not location then return '' end
|
||||
|
||||
if location.line and location.col then
|
||||
return string.format(':%d:%d', location.line, location.col)
|
||||
elseif location.line then
|
||||
return string.format(':%d', location.line)
|
||||
elseif location.start and location['end'] then
|
||||
-- Handle nested structure with optional column information
|
||||
if location.start.col and location['end'].col then
|
||||
return string.format(
|
||||
':%d:%d-%d:%d',
|
||||
location.start.line,
|
||||
location.start.col,
|
||||
location['end'].line,
|
||||
location['end'].col
|
||||
)
|
||||
else
|
||||
return string.format(':%d-%d', location.start.line, location['end'].line)
|
||||
end
|
||||
end
|
||||
|
||||
return ''
|
||||
end
|
||||
|
||||
return M
|
||||
+116
-403
@@ -1,302 +1,74 @@
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end
|
||||
|
||||
local M = {}
|
||||
M.config = {}
|
||||
|
||||
M.state = { initialized = false }
|
||||
|
||||
--- Setup the file picker with the given configuration
|
||||
--- @param config table Configuration options
|
||||
function M.setup(config)
|
||||
local default_config = {
|
||||
base_path = vim.fn.getcwd(),
|
||||
max_results = 100,
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
title = 'FFF Files', -- Window title
|
||||
width = 0.8,
|
||||
height = 0.8,
|
||||
preview = {
|
||||
enabled = true,
|
||||
width = 0.5,
|
||||
max_lines = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
layout = {
|
||||
prompt_position = 'top',
|
||||
preview_position = 'right',
|
||||
preview_width = 0.4,
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
},
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
debug = {
|
||||
enabled = false,
|
||||
show_scores = false,
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
ui = {
|
||||
wrap_paths = true,
|
||||
wrap_indent = 2,
|
||||
max_path_width = 80,
|
||||
},
|
||||
image_preview = {
|
||||
enabled = true,
|
||||
max_width = 80,
|
||||
max_height = 24,
|
||||
},
|
||||
icons = {
|
||||
enabled = true,
|
||||
},
|
||||
ui_enabled = true,
|
||||
}
|
||||
|
||||
local merged_config = vim.tbl_deep_extend('force', default_config, config or {})
|
||||
M.config = merged_config
|
||||
|
||||
local db_path = merged_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
|
||||
|
||||
ok, result = pcall(fuzzy.init_file_picker, merged_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.config = merged_config
|
||||
|
||||
M.setup_default_keymaps()
|
||||
M.setup_commands()
|
||||
|
||||
if merged_config.frecency.enabled then M.setup_global_file_tracking() end
|
||||
|
||||
local git_utils = require('fff.git_utils')
|
||||
git_utils.setup_highlights()
|
||||
|
||||
if merged_config.logging.enabled then
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local log_success, log_error =
|
||||
pcall(fuzzy.init_tracing, merged_config.logging.log_file, merged_config.logging.log_level)
|
||||
if log_success then
|
||||
M.log_file_path = log_error
|
||||
else
|
||||
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Setup default keymaps
|
||||
function M.setup_default_keymaps()
|
||||
vim.keymap.set('n', '<leader>ff', function() M.find_files() end, { desc = 'Find files' })
|
||||
vim.keymap.set('n', '<leader>ft', function() M.toggle() end, { desc = 'Toggle file picker' })
|
||||
vim.keymap.set('n', '<leader>fg', function() M.find_in_git_root() end, { desc = 'Find files in git root' })
|
||||
vim.keymap.set('n', '<leader>fr', function() M.find_recent() end, { desc = 'Find recent files' })
|
||||
end
|
||||
|
||||
function M.setup_global_file_tracking()
|
||||
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
|
||||
|
||||
vim.api.nvim_create_autocmd({ 'BufRead', 'BufNewFile' }, {
|
||||
group = group,
|
||||
callback = function(args)
|
||||
local file_path = args.file
|
||||
|
||||
if file_path and file_path ~= '' and not vim.startswith(file_path, 'term://') then
|
||||
-- never block the UI
|
||||
vim.schedule(function()
|
||||
local stat = vim.loop.fs_stat(file_path)
|
||||
if stat and stat.type == 'file' then
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local relative_path = vim.fn.fnamemodify(file_path, ':.')
|
||||
pcall(fuzzy.access_file, relative_path)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end,
|
||||
desc = 'Track file access for FFF frecency',
|
||||
})
|
||||
end
|
||||
|
||||
function M.setup_commands()
|
||||
vim.api.nvim_create_user_command('FFFFind', function(opts)
|
||||
if opts.args and opts.args ~= '' then
|
||||
-- If argument looks like a directory, use it as base path
|
||||
if vim.fn.isdirectory(opts.args) == 1 then
|
||||
M.find_files_in_dir(opts.args)
|
||||
else
|
||||
-- Otherwise treat as search query
|
||||
M.search_and_show(opts.args)
|
||||
end
|
||||
else
|
||||
M.find_files()
|
||||
end
|
||||
end, {
|
||||
nargs = '?',
|
||||
complete = function(arg_lead, cmd_line, cursor_pos)
|
||||
-- Complete with directories and common search terms
|
||||
local dirs = vim.fn.glob(arg_lead .. '*', false, true)
|
||||
local results = {}
|
||||
for _, dir in ipairs(dirs) do
|
||||
if vim.fn.isdirectory(dir) == 1 then table.insert(results, dir) end
|
||||
end
|
||||
return results
|
||||
end,
|
||||
desc = 'Find files with FFF (use directory path or search query)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFScan', function() M.scan_files() end, {
|
||||
desc = 'Scan files for FFF',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFRefreshGit', function() M.refresh_git_status() end, {
|
||||
desc = 'Manually refresh git status for all files',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFClearCache', function(opts) M.clear_cache(opts.args) end, {
|
||||
nargs = '?',
|
||||
complete = function(arg_lead, cmd_line, cursor_pos) return { 'all', 'frecency', 'files' } end,
|
||||
desc = 'Clear FFF caches (all|frecency|files)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFHealth', function() M.health_check() end, {
|
||||
desc = 'Check FFF health',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFDebug', function(opts)
|
||||
if opts.args == 'toggle' or opts.args == '' then
|
||||
M.config.debug.show_scores = not M.config.debug.show_scores
|
||||
local status = M.config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
elseif opts.args == 'on' then
|
||||
M.config.debug.show_scores = true
|
||||
vim.notify('FFF debug scores enabled', vim.log.levels.INFO)
|
||||
elseif opts.args == 'off' then
|
||||
M.config.debug.show_scores = false
|
||||
vim.notify('FFF debug scores disabled', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('Usage: :FFFDebug [on|off|toggle]', vim.log.levels.ERROR)
|
||||
end
|
||||
end, {
|
||||
nargs = '?',
|
||||
complete = function() return { 'on', 'off', 'toggle' } end,
|
||||
desc = 'Toggle FFF debug scores display',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFOpenLog', function()
|
||||
if M.log_file_path then
|
||||
vim.cmd('tabnew ' .. vim.fn.fnameescape(M.log_file_path))
|
||||
elseif M.config and M.config.logging and M.config.logging.log_file then
|
||||
-- Fallback to the configured log file path even if tracing wasn't initialized
|
||||
vim.cmd('tabnew ' .. vim.fn.fnameescape(M.config.logging.log_file))
|
||||
else
|
||||
vim.notify('Log file path not available', vim.log.levels.ERROR)
|
||||
end
|
||||
end, {
|
||||
desc = 'Open FFF log file in new tab',
|
||||
})
|
||||
end
|
||||
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
|
||||
|
||||
--- Find files in specific directory
|
||||
--- @param dir string Directory path
|
||||
function M.find_files_in_dir(dir)
|
||||
--- Live grep: search file contents in the current directory
|
||||
--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string} Optional configuration overrides
|
||||
function M.live_grep(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ cwd = dir })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
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', {
|
||||
title = 'Live Grep',
|
||||
mode = 'grep',
|
||||
renderer = grep_renderer,
|
||||
grep_config = grep_config,
|
||||
query = '',
|
||||
}, opts or {})
|
||||
|
||||
picker_ui.open(picker_opts)
|
||||
end
|
||||
|
||||
--- Find files in git repository root
|
||||
--- Changes the directory indexed by the file picker to the git root and opens the file picker
|
||||
--- @deprecated Use `find_files` instead
|
||||
function M.find_in_git_root()
|
||||
-- Check if we're in a git repo first
|
||||
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
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Git Files', cwd = git_root })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
M.find_files_in_dir(git_root)
|
||||
end
|
||||
|
||||
--- Find recent files (frecency based)
|
||||
function M.find_recent()
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Recent Files' })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
--- Toggle file picker
|
||||
function M.toggle() M.find_files() end
|
||||
|
||||
--- Scan files
|
||||
--- Trigger rescan of files in the current directory
|
||||
function M.scan_files()
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok = pcall(fuzzy.scan_files)
|
||||
if ok then
|
||||
local cached_files = pcall(fuzzy.get_cached_files) and fuzzy.get_cached_files() or {}
|
||||
print('Triggered file scan (currently ' .. #cached_files .. ' files cached)')
|
||||
else
|
||||
vim.notify('Failed to scan files', vim.log.levels.ERROR)
|
||||
end
|
||||
if not ok then vim.notify('Failed to scan files', vim.log.levels.ERROR) end
|
||||
end
|
||||
|
||||
--- Refresh git status for all cached files
|
||||
--- Refresh git status for the active file lock
|
||||
function M.refresh_git_status()
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local ok, files = pcall(fuzzy.refresh_git_status)
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, updated_files_count = pcall(fuzzy.refresh_git_status)
|
||||
if ok then
|
||||
print('Refreshed git status for ' .. #files .. ' files')
|
||||
vim.notify('Refreshed git status for ' .. tostring(updated_files_count) .. ' files', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('Failed to refresh git status', vim.log.levels.ERROR)
|
||||
end
|
||||
@@ -307,9 +79,23 @@ end
|
||||
--- @param max_results number Maximum number of results
|
||||
--- @return table List of matching files
|
||||
function M.search(query, max_results)
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
max_results = max_results or M.config.max_results
|
||||
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil)
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
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
|
||||
@@ -371,149 +157,76 @@ function M.get_preview(file_path)
|
||||
return table.concat(lines, '\n')
|
||||
end
|
||||
|
||||
function M.debug_file_ordering()
|
||||
print('FFF Debug File Ordering')
|
||||
print('=======================')
|
||||
|
||||
if not M.is_initialized() then
|
||||
print('File picker not initialized. Run :FFFScan first.')
|
||||
--- Find files in a specific directory
|
||||
--- @param directory string Directory path to search in
|
||||
function M.find_files_in_dir(directory)
|
||||
if not directory then
|
||||
vim.notify('Directory path required for find_files_in_dir', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local picker = M.picker or M.core
|
||||
print('Getting top 10 files with debug info...')
|
||||
M.change_indexing_directory(directory)
|
||||
|
||||
-- Enable debug mode temporarily
|
||||
local old_debug = M.config.debug.show_scores
|
||||
M.config.debug.show_scores = true
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
-- Search with empty query to get default ordering
|
||||
local files = picker.search_files('', 10)
|
||||
--- Change the base directory for the file picker
|
||||
--- @param new_path string New directory path to use as base
|
||||
--- @return boolean `true` if successful, `false` otherwise
|
||||
function M.change_indexing_directory(new_path)
|
||||
if not new_path or new_path == '' then
|
||||
vim.notify('Directory path is required', vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
print('🏆 TOP FILES (in order they appear):')
|
||||
print('=' .. string.rep('=', 70))
|
||||
local expanded_path = vim.fn.expand(new_path)
|
||||
|
||||
for i, file in ipairs(files) do
|
||||
local frecency_stars = ''
|
||||
if file.frecency_score > 0 then frecency_stars = ' ⭐' .. file.frecency_score end
|
||||
if vim.fn.isdirectory(expanded_path) ~= 1 then
|
||||
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Extract directory information
|
||||
local dir = vim.fn.fnamemodify(file.relative_path, ':h')
|
||||
local filename = vim.fn.fnamemodify(file.relative_path, ':t')
|
||||
local dir_display = (dir == '.' or dir == '') and 'root' or dir
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Get score information for this file
|
||||
local score = picker.get_file_score(i)
|
||||
local config = require('fff.conf').get()
|
||||
config.base_path = expanded_path
|
||||
return true
|
||||
end
|
||||
|
||||
print(string.format('%2d. %s%s', i, filename, frecency_stars))
|
||||
print(string.format(' Path: %s/', dir_display))
|
||||
print(string.format(' Debug: %s', score and score.match_type or 'no debug info'))
|
||||
if score then
|
||||
print(
|
||||
string.format(
|
||||
' Total Score: %d (base=%d, name_bonus=%d, special_bonus=%d, frec=%d, dist=%d)',
|
||||
score.total,
|
||||
score.base_score,
|
||||
score.filename_bonus,
|
||||
score.special_filename_bonus,
|
||||
score.frecency_boost,
|
||||
score.distance_penalty
|
||||
)
|
||||
)
|
||||
--- Opens the file under the cursor with an optional callback if the only file
|
||||
--- is found and we are about to inline open it
|
||||
--- @param open_cb function|nil Optional callback function to execute after opening the file
|
||||
function M.open_file_under_cursor(open_cb)
|
||||
local full_path_with_suffix = vim.fn.expand('<cWORD>')
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if not picker_ok then
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
|
||||
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
|
||||
if open_cb and type(open_cb) == 'function' then open_cb(files[1].path) end
|
||||
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].path)))
|
||||
|
||||
if location then vim.schedule(function() require('fff.location_utils').jump_to_location(location) end) end
|
||||
|
||||
return true
|
||||
else
|
||||
print(' Total Score: N/A (no score data)')
|
||||
return false -- Open UI with results
|
||||
end
|
||||
|
||||
local now = os.time()
|
||||
local age_hours = math.floor((now - file.modified) / 3600)
|
||||
local age_days = math.floor(age_hours / 24)
|
||||
print(string.format(' Age: %d hours (%d days) since last modified', age_hours, age_days))
|
||||
print('')
|
||||
end
|
||||
|
||||
print('💡 EXPLANATION:')
|
||||
print('• Files are sorted by FRECENCY first (⭐ score), then by modification time')
|
||||
print('• Frecency combines how often AND how recently you accessed files')
|
||||
print('• The file at #1 has either:')
|
||||
print(' - Highest frecency score, OR')
|
||||
print(' - Same frecency as others but most recent modification')
|
||||
|
||||
M.config.debug.show_scores = old_debug
|
||||
end)
|
||||
end
|
||||
|
||||
function M.health_check()
|
||||
local health = {
|
||||
ok = true,
|
||||
messages = {},
|
||||
}
|
||||
|
||||
local errors = check_dependencies()
|
||||
if #errors > 0 then
|
||||
health.ok = false
|
||||
for _, error in ipairs(errors) do
|
||||
table.insert(health.messages, error)
|
||||
end
|
||||
end
|
||||
|
||||
local ui_errors = check_ui_dependencies()
|
||||
if #ui_errors > 0 then
|
||||
table.insert(health.messages, 'UI not available: ' .. table.concat(ui_errors, ', '))
|
||||
else
|
||||
table.insert(health.messages, '✓ UI available')
|
||||
end
|
||||
|
||||
if not M.is_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
|
||||
|
||||
function M.get_status()
|
||||
local status = 'No files indexed'
|
||||
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local ok, cached_files = pcall(fuzzy.get_cached_files)
|
||||
if ok and cached_files and #cached_files > 0 then status = string.format('%d files indexed', #cached_files) end
|
||||
|
||||
if M.config and M.config.frecency and M.config.frecency.enabled then
|
||||
status = status .. ' • Frecency tracking enabled'
|
||||
end
|
||||
|
||||
return status
|
||||
end
|
||||
|
||||
function M.is_initialized() return M.state and M.state.initialized or false end
|
||||
|
||||
return M
|
||||
|
||||
+2168
-617
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileKey {
|
||||
pub path: String,
|
||||
}
|
||||
@@ -1,699 +0,0 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_key::FileKey;
|
||||
use crate::git::{format_git_status, GitStatusCache};
|
||||
use crate::score::match_and_score_files;
|
||||
use crate::types::{FileItem, Score, ScoringContext, SearchResult};
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
use notify::{EventKind, RecursiveMode};
|
||||
use notify_debouncer_full::{new_debouncer, DebounceEventResult, DebouncedEvent};
|
||||
use rayon::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, RwLock,
|
||||
};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::FRECENCY;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FileSync {
|
||||
files: Vec<FileItem>,
|
||||
last_update: SystemTime,
|
||||
git_status_cache: Option<GitStatusCache>,
|
||||
scan_generation: u64,
|
||||
}
|
||||
|
||||
impl FileSync {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Vec::new(),
|
||||
last_update: SystemTime::UNIX_EPOCH,
|
||||
git_status_cache: None,
|
||||
scan_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_files(&mut self, mut files: Vec<FileItem>, git_status_cache: Option<GitStatusCache>) {
|
||||
files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
|
||||
|
||||
self.files = files;
|
||||
self.git_status_cache = git_status_cache;
|
||||
self.last_update = SystemTime::now();
|
||||
self.scan_generation = self.scan_generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn contains_path(&self, path: &str) -> bool {
|
||||
self.files
|
||||
.binary_search_by(|file| file.relative_path.as_str().cmp(path))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn find_file_index(&self, path: &str) -> Result<usize, usize> {
|
||||
self.files
|
||||
.binary_search_by(|file| file.relative_path.as_str().cmp(path))
|
||||
}
|
||||
|
||||
fn insert_file_sorted(&mut self, file: FileItem) {
|
||||
match self
|
||||
.files
|
||||
.binary_search_by(|f| f.relative_path.cmp(&file.relative_path))
|
||||
{
|
||||
Ok(_) => {
|
||||
warn!(
|
||||
"Trying to insert a file that already exists: {}",
|
||||
file.relative_path
|
||||
);
|
||||
}
|
||||
Err(pos) => {
|
||||
self.files.insert(pos, file);
|
||||
self.scan_generation = self.scan_generation.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove file by path using binary search
|
||||
fn remove_file_by_path(&mut self, path: &str) -> bool {
|
||||
match self.find_file_index(path) {
|
||||
Ok(index) => {
|
||||
self.files.remove(index);
|
||||
self.scan_generation = self.scan_generation.wrapping_add(1);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileItem {
|
||||
fn new(path: PathBuf, base_path: &Path, git_status: Option<Status>) -> Self {
|
||||
let relative_path = pathdiff::diff_paths(&path, base_path)
|
||||
.unwrap_or_else(|| path.clone())
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let extension = path
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let directory = match Path::new(&relative_path).parent() {
|
||||
Some(parent) if parent != Path::new(".") && !parent.as_os_str().is_empty() => {
|
||||
parent.to_string_lossy().into_owned()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let (size, modified) = match std::fs::metadata(&path) {
|
||||
Ok(metadata) => {
|
||||
let size = metadata.len();
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_secs());
|
||||
|
||||
(size, modified)
|
||||
}
|
||||
Err(_) => (0, 0),
|
||||
};
|
||||
|
||||
Self {
|
||||
path,
|
||||
relative_path,
|
||||
file_name: name,
|
||||
extension,
|
||||
directory,
|
||||
size,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
is_current_file: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_frecency_scores(&mut self) {
|
||||
if let Ok(frecency) = FRECENCY.read() {
|
||||
if let Some(ref tracker) = *frecency {
|
||||
let file_key = FileKey::from(&*self);
|
||||
self.access_frecency_score = tracker.get_access_score(&file_key);
|
||||
self.modification_frecency_score = tracker
|
||||
.get_modification_score(self.modified, format_git_status(self.git_status));
|
||||
self.total_frecency_score =
|
||||
self.access_frecency_score + self.modification_frecency_score;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&FileItem> for FileKey {
|
||||
fn from(file: &FileItem) -> Self {
|
||||
FileKey {
|
||||
path: file.relative_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilePicker {
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
sync_data: Arc<RwLock<FileSync>>,
|
||||
shutdown_signal: Arc<AtomicBool>,
|
||||
is_scanning: Arc<AtomicBool>,
|
||||
_background_handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FilePicker")
|
||||
.field("base_path", &self.base_path)
|
||||
.field("git_workdir", &self.git_workdir)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl FilePicker {
|
||||
pub fn new(base_path: String) -> Result<Self, Error> {
|
||||
info!("Initializing FilePicker with base_path: {}", base_path);
|
||||
let path = PathBuf::from(&base_path);
|
||||
if !path.exists() {
|
||||
error!("Base path does not exist: {}", base_path);
|
||||
return Err(Error::InvalidPath(path.to_string_lossy().into_owned()));
|
||||
}
|
||||
|
||||
let git_workdir = Repository::discover(&path)
|
||||
.ok()
|
||||
.and_then(|repo| repo.workdir().map(Path::to_path_buf));
|
||||
|
||||
if let Some(ref git_dir) = git_workdir {
|
||||
debug!("Git repository found at: {}", git_dir.display());
|
||||
} else {
|
||||
debug!("No git repository found for path: {}", base_path);
|
||||
}
|
||||
|
||||
let sync_data = Arc::new(RwLock::new(FileSync::new()));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let scan_signal = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let background_handle = spawn_background_watcher(
|
||||
path.clone(),
|
||||
git_workdir.clone(),
|
||||
Arc::clone(&sync_data),
|
||||
Arc::clone(&shutdown),
|
||||
Arc::clone(&scan_signal),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
base_path: path,
|
||||
git_workdir,
|
||||
sync_data,
|
||||
shutdown_signal: shutdown,
|
||||
is_scanning: scan_signal,
|
||||
_background_handle: Some(background_handle),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fuzzy_search(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
max_threads: usize,
|
||||
current_file: Option<&String>,
|
||||
) -> SearchResult {
|
||||
let max_threads = max_threads.max(1); // Ensure at least 1 to avoid neo_frizbee division by zero
|
||||
|
||||
debug!(
|
||||
"Fuzzy search: query='{}', max_results={}, max_threads={}, current_file={:?}",
|
||||
query, max_results, max_threads, current_file
|
||||
);
|
||||
|
||||
let time = std::time::Instant::now();
|
||||
let sync_data = self.sync_data.read().unwrap();
|
||||
let total_files = sync_data.files.len();
|
||||
|
||||
// small queries with a large number of results can match absolutely everything
|
||||
let max_typos = (query.len() as u16 / 4).clamp(2, 6);
|
||||
let context = ScoringContext {
|
||||
query,
|
||||
max_typos,
|
||||
max_threads,
|
||||
current_file,
|
||||
};
|
||||
|
||||
let scored_indices = match_and_score_files(&sync_data.files, &context);
|
||||
let total_matched = scored_indices.len();
|
||||
|
||||
let mut scored_files: Vec<(FileItem, Score)> = scored_indices
|
||||
.into_par_iter()
|
||||
.map(|(idx, score)| (sync_data.files[idx].clone(), score))
|
||||
.collect();
|
||||
|
||||
scored_files.par_sort_unstable_by(|a, b| {
|
||||
b.1.total
|
||||
.cmp(&a.1.total)
|
||||
.then_with(|| b.0.modified.cmp(&a.0.modified))
|
||||
});
|
||||
|
||||
scored_files.truncate(max_results);
|
||||
|
||||
let (items, scores): (Vec<FileItem>, Vec<Score>) = scored_files.into_iter().unzip();
|
||||
|
||||
debug!(
|
||||
"Fuzzy search completed: found {} results for query '{}', total_matched={}, total_files={}, top result {:?}",
|
||||
items.len(),
|
||||
query,
|
||||
total_matched,
|
||||
total_files,
|
||||
items.first()
|
||||
);
|
||||
|
||||
debug!("Total search time: {:?}", time.elapsed());
|
||||
SearchResult {
|
||||
items,
|
||||
scores,
|
||||
total_matched,
|
||||
total_files,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cached_files(&self) -> Vec<FileItem> {
|
||||
self.sync_data.read().unwrap().files.clone()
|
||||
}
|
||||
|
||||
pub fn get_scan_progress(&self) -> ScanProgress {
|
||||
let sync_data = self.sync_data.read().unwrap();
|
||||
let is_scanning = self.is_scanning.load(Ordering::Relaxed);
|
||||
ScanProgress {
|
||||
total_files: sync_data.files.len(),
|
||||
scanned_files: sync_data.files.len(),
|
||||
is_scanning,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(&self) -> Vec<FileItem> {
|
||||
let sync_data: &Arc<RwLock<FileSync>> = &self.sync_data;
|
||||
let git_workdir = self.git_workdir.as_deref();
|
||||
let new_git_status_cache = GitStatusCache::read_git_status(git_workdir);
|
||||
|
||||
if let Ok(mut sync_data_write) = sync_data.write() {
|
||||
sync_data_write.git_status_cache = new_git_status_cache.clone();
|
||||
|
||||
for file in &mut sync_data_write.files {
|
||||
file.git_status = new_git_status_cache
|
||||
.as_ref()
|
||||
.and_then(|git| git.lookup_status(&file.path));
|
||||
|
||||
file.update_frecency_scores();
|
||||
}
|
||||
}
|
||||
|
||||
self.get_cached_files()
|
||||
}
|
||||
|
||||
pub fn trigger_rescan(&self) -> Result<(), Error> {
|
||||
if self.is_scanning.load(Ordering::Relaxed) {
|
||||
debug!("Scan already in progress, skipping trigger_rescan");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("is_scanning = TRUE (manual rescan starting)");
|
||||
self.is_scanning.store(true, Ordering::Relaxed);
|
||||
|
||||
let base_path = self.base_path.clone();
|
||||
let git_workdir = self.git_workdir.clone();
|
||||
let sync_data = Arc::clone(&self.sync_data);
|
||||
let scan_signal = Arc::clone(&self.is_scanning);
|
||||
|
||||
thread::spawn(move || {
|
||||
debug!("Background scan thread started");
|
||||
if let Ok((files, git_cache)) = scan_filesystem(&base_path, git_workdir.as_ref()) {
|
||||
info!("Filesystem scan completed: found {} files", files.len());
|
||||
if let Ok(mut data) = sync_data.write() {
|
||||
data.update_files(files, git_cache);
|
||||
debug!("File cache updated successfully");
|
||||
}
|
||||
} else {
|
||||
warn!("Filesystem scan failed");
|
||||
}
|
||||
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
info!("is_scanning = FALSE (manual rescan completed)");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_scan_active(&self) -> bool {
|
||||
self.is_scanning.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(&self) {
|
||||
self.shutdown_signal.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanProgress {
|
||||
pub total_files: usize,
|
||||
pub scanned_files: usize,
|
||||
pub is_scanning: bool,
|
||||
}
|
||||
|
||||
fn spawn_background_watcher(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
sync_data: Arc<RwLock<FileSync>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
scan_signal: Arc<AtomicBool>,
|
||||
) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
scan_signal.store(true, Ordering::Relaxed);
|
||||
info!("starting background watcher thread");
|
||||
|
||||
match scan_filesystem(&base_path, git_workdir.as_ref()) {
|
||||
Ok((files, git_cache)) => {
|
||||
info!(
|
||||
"Initial parallel filesystem scan completed: found {} files",
|
||||
files.len()
|
||||
);
|
||||
if let Ok(mut data) = sync_data.write() {
|
||||
data.update_files(files, git_cache);
|
||||
debug!("Initial file cache updated successfully");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to scan filesystem: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
error!("is_scanning = FALSE (initial scan completed)");
|
||||
|
||||
let mut debouncer = match new_debouncer(Duration::from_millis(500), None, {
|
||||
let sync_data = Arc::clone(&sync_data);
|
||||
let base_path = base_path.clone();
|
||||
let git_workdir = git_workdir.clone();
|
||||
|
||||
move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
handle_debounced_events(events, &sync_data, &base_path, &git_workdir);
|
||||
}
|
||||
Err(errors) => {
|
||||
error!("File watcher errors: {:?}", errors);
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Ok(debouncer) => debouncer,
|
||||
Err(e) => {
|
||||
error!("Failed to create debouncer: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = debouncer.watch(&base_path, RecursiveMode::Recursive) {
|
||||
error!("Failed to start watching: {:?}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_debounced_events(
|
||||
events: Vec<DebouncedEvent>,
|
||||
sync_data: &Arc<RwLock<FileSync>>,
|
||||
base_path: &Path,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
) {
|
||||
let mut affected_paths = Vec::new();
|
||||
for event in events {
|
||||
let relevant_paths: Vec<_> = event
|
||||
.paths
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let relative_path = pathdiff::diff_paths(path, base_path)?;
|
||||
|
||||
let Ok(sync_read) = sync_data.read() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let relative_str = relative_path.to_string_lossy();
|
||||
|
||||
if sync_read.contains_path(&relative_str) {
|
||||
return Some(path.clone());
|
||||
}
|
||||
|
||||
match event.event.kind {
|
||||
EventKind::Create(_) => {
|
||||
if should_add_new_file(path, git_workdir.as_ref()) {
|
||||
Some(path.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if relevant_paths.is_empty() {
|
||||
continue; // No relevant paths to process
|
||||
}
|
||||
|
||||
debug!(?event, "File watcher event");
|
||||
match event.event.kind {
|
||||
EventKind::Create(_) => {
|
||||
handle_create_events(&relevant_paths, sync_data, base_path, git_workdir.as_ref());
|
||||
affected_paths.extend(relevant_paths);
|
||||
}
|
||||
EventKind::Modify(_) => {
|
||||
affected_paths.extend(relevant_paths);
|
||||
}
|
||||
EventKind::Remove(_) => {
|
||||
remove_paths_from_index(relevant_paths, sync_data, base_path);
|
||||
}
|
||||
_ => {
|
||||
affected_paths.extend(relevant_paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !affected_paths.is_empty() {
|
||||
update_git_status_for_paths(sync_data, git_workdir, base_path, &affected_paths);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_add_new_file(path: &Path, git_workdir: Option<&PathBuf>) -> bool {
|
||||
if is_git_file(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(git_workdir) = git_workdir {
|
||||
if let Ok(repo) = Repository::open(git_workdir) {
|
||||
if repo.is_path_ignored(path).unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_create_events(
|
||||
paths: &[PathBuf],
|
||||
sync_data: &Arc<RwLock<FileSync>>,
|
||||
base_path: &Path,
|
||||
git_workdir: Option<&PathBuf>,
|
||||
) {
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
if let Ok(mut sync_write) = sync_data.write() {
|
||||
for path in paths {
|
||||
if repo
|
||||
.as_ref()
|
||||
.is_some_and(|repo| repo.is_path_ignored(path).unwrap_or(false))
|
||||
{
|
||||
debug!("Ignoring file {} due to gitignore rules", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut file_item = FileItem::new(path.clone(), base_path, None);
|
||||
file_item.update_frecency_scores();
|
||||
sync_write.insert_file_sorted(file_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_paths_from_index(
|
||||
paths: Vec<PathBuf>,
|
||||
sync_data: &Arc<RwLock<FileSync>>,
|
||||
base_path: &Path,
|
||||
) {
|
||||
if let Ok(mut sync_write) = sync_data.write() {
|
||||
for path in paths {
|
||||
if let Some(relative_path) = pathdiff::diff_paths(path, base_path) {
|
||||
let relative_str = relative_path.to_string_lossy();
|
||||
sync_write.remove_file_by_path(&relative_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_filesystem(
|
||||
base_path: &Path,
|
||||
git_workdir: Option<&PathBuf>,
|
||||
) -> Result<(Vec<FileItem>, Option<GitStatusCache>), Error> {
|
||||
let scan_start = std::time::Instant::now();
|
||||
let git_workdir = git_workdir.map(|p| p.as_path());
|
||||
info!("SCAN: Starting parallel filesystem scan and git status");
|
||||
|
||||
// run separate thread for git status because it effectively does another separate file
|
||||
// traversal which could be pretty slow on large repos (in general 300-500ms)
|
||||
thread::scope(|s| {
|
||||
let git_handle = s.spawn(|| GitStatusCache::read_git_status(git_workdir));
|
||||
|
||||
let walker = WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.sort_by_file_name(std::cmp::Ord::cmp)
|
||||
.build_parallel();
|
||||
|
||||
let walker_start = std::time::Instant::now();
|
||||
info!("SCAN: Starting file walker");
|
||||
|
||||
let files = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
walker.run(|| {
|
||||
let files = Arc::clone(&files);
|
||||
let base_path = base_path.to_path_buf();
|
||||
|
||||
Box::new(move |result| {
|
||||
if let Ok(entry) = result {
|
||||
if let Some(file_type) = entry.file_type() {
|
||||
if file_type.is_file() {
|
||||
let path = entry.path();
|
||||
|
||||
if is_git_file(path) {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
|
||||
let file_item = FileItem::new(
|
||||
path.to_path_buf(),
|
||||
&base_path,
|
||||
None, // Git status will be added after join
|
||||
);
|
||||
|
||||
if let Ok(mut files_vec) = files.lock() {
|
||||
files_vec.push(file_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
let mut files = Arc::try_unwrap(files).unwrap().into_inner().unwrap();
|
||||
let walker_time = walker_start.elapsed();
|
||||
info!("SCAN: File walking completed in {:?}", walker_time);
|
||||
|
||||
let git_cache = git_handle
|
||||
.join()
|
||||
.map_err(|_| Error::InvalidPath("Git status thread panicked".to_string()))?;
|
||||
|
||||
if let Some(git_cache) = &git_cache {
|
||||
files.par_iter_mut().for_each(|file| {
|
||||
file.git_status = git_cache.lookup_status(&file.path);
|
||||
file.update_frecency_scores();
|
||||
});
|
||||
}
|
||||
|
||||
let total_time = scan_start.elapsed();
|
||||
info!(
|
||||
"SCAN: Total scan time {:?} for {} files",
|
||||
total_time,
|
||||
files.len()
|
||||
);
|
||||
|
||||
Ok((files, git_cache))
|
||||
})
|
||||
}
|
||||
|
||||
fn update_git_status_for_paths(
|
||||
sync_data: &Arc<RwLock<FileSync>>,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
base_path: &Path,
|
||||
affected_paths: &[PathBuf],
|
||||
) {
|
||||
let Some(git_workdir) = git_workdir else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(repo) = Repository::open(git_workdir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut status_options = StatusOptions::new();
|
||||
status_options.include_untracked(true);
|
||||
status_options.include_ignored(false);
|
||||
|
||||
for path in affected_paths {
|
||||
if let Some(relative_path) = pathdiff::diff_paths(path, base_path) {
|
||||
let path_str = relative_path.to_string_lossy();
|
||||
status_options.pathspec(&*path_str);
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(statuses) = repo.statuses(Some(&mut status_options)) else {
|
||||
error!(
|
||||
"Failed to get git statuses for affected paths: {:?}",
|
||||
affected_paths
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(mut sync_write) = sync_data.write() {
|
||||
for status_entry in statuses.iter() {
|
||||
let Some(file_path) = status_entry.path() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(index) = sync_write.find_file_index(file_path) {
|
||||
sync_write.files[index].git_status = Some(status_entry.status());
|
||||
sync_write.files[index].update_frecency_scores();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_git_file(path: &Path) -> bool {
|
||||
path.to_str().is_some_and(|path| path.contains("/.git/"))
|
||||
}
|
||||
|
||||
impl Drop for FilePicker {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown_signal.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStatusCache {
|
||||
paths: Vec<PathBuf>,
|
||||
statuses: Vec<Status>,
|
||||
}
|
||||
|
||||
impl GitStatusCache {
|
||||
fn from_git_entries(mut entries: Vec<(PathBuf, Status)>) -> Self {
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let (paths, statuses) = entries.into_iter().unzip();
|
||||
Self { paths, statuses }
|
||||
}
|
||||
|
||||
pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
|
||||
match self
|
||||
.paths
|
||||
.binary_search_by(|probe| probe.as_path().cmp(full_path))
|
||||
{
|
||||
Ok(idx) => self.statuses.get(idx).copied(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_git_status(git_workdir: Option<&Path>) -> Option<Self> {
|
||||
let git_start = std::time::Instant::now();
|
||||
info!("GIT: Starting git status read");
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let git_workdir = git_workdir.as_ref()?;
|
||||
let repository = Repository::open(git_workdir).ok()?;
|
||||
|
||||
let status_start = std::time::Instant::now();
|
||||
let statuses = repository
|
||||
.statuses(Some(&mut StatusOptions::new().include_untracked(true)))
|
||||
.map_err(|e| {
|
||||
error!("Failed to get git statuses: {}", e);
|
||||
e
|
||||
})
|
||||
.ok()?;
|
||||
let status_time = status_start.elapsed();
|
||||
info!("GIT: Status query completed in {:?}", status_time);
|
||||
|
||||
info!(
|
||||
"GIT STATUS: {} git entries in {}",
|
||||
statuses.len(),
|
||||
git_workdir.display()
|
||||
);
|
||||
|
||||
for entry in &statuses {
|
||||
if let Some(entry_path) = entry.path() {
|
||||
let full_path = git_workdir.join(entry_path);
|
||||
entries.push((full_path, entry.status()));
|
||||
}
|
||||
}
|
||||
|
||||
let total_time = git_start.elapsed();
|
||||
info!("GIT: Total git status read time {:?}", total_time);
|
||||
|
||||
Some(Self::from_git_entries(entries))
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_modified_status(status: Status) -> bool {
|
||||
status.intersects(
|
||||
Status::WT_MODIFIED
|
||||
| Status::INDEX_MODIFIED
|
||||
| Status::WT_NEW
|
||||
| Status::INDEX_NEW
|
||||
| Status::WT_RENAMED,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
match status {
|
||||
None => "clear",
|
||||
Some(status) => {
|
||||
if status.contains(Status::WT_NEW) {
|
||||
"untracked"
|
||||
} else if status.contains(Status::WT_MODIFIED) {
|
||||
"modified"
|
||||
} else if status.contains(Status::WT_DELETED) {
|
||||
"deleted"
|
||||
} else if status.contains(Status::WT_RENAMED) {
|
||||
"renamed"
|
||||
} else if status.contains(Status::INDEX_NEW) {
|
||||
"staged_new"
|
||||
} else if status.contains(Status::INDEX_MODIFIED) {
|
||||
"staged_modified"
|
||||
} else if status.contains(Status::INDEX_DELETED) {
|
||||
"staged_deleted"
|
||||
} else if status.contains(Status::IGNORED) {
|
||||
"ignored"
|
||||
} else if status.contains(Status::CURRENT) || status.is_empty() {
|
||||
"clean"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-14
@@ -1,21 +1,76 @@
|
||||
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('@?(.*/)')
|
||||
package.cpath = package.cpath
|
||||
.. ';'
|
||||
.. base_path
|
||||
.. '../../../target/release/lib?'
|
||||
.. get_lib_extension()
|
||||
.. ';'
|
||||
.. base_path
|
||||
.. '../../../target/release/?'
|
||||
.. get_lib_extension()
|
||||
--- 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
|
||||
|
||||
return require('fff_nvim')
|
||||
-- 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(),
|
||||
base_path .. '../../../target/release/lib?' .. get_lib_extension(),
|
||||
base_path .. '../../../target/release/?' .. get_lib_extension(),
|
||||
}
|
||||
|
||||
local cargo_target_dir = os.getenv('CARGO_TARGET_DIR')
|
||||
if cargo_target_dir then
|
||||
table.insert(paths, cargo_target_dir .. '/release/lib?' .. get_lib_extension())
|
||||
table.insert(paths, cargo_target_dir .. '/release/?' .. get_lib_extension())
|
||||
end
|
||||
|
||||
-- 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 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
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_key::FileKey;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::types::{FileItem, SearchResult};
|
||||
use mlua::prelude::*;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
mod error;
|
||||
mod file_key;
|
||||
mod file_picker;
|
||||
mod frecency;
|
||||
mod git;
|
||||
mod path_utils;
|
||||
pub(crate) mod score;
|
||||
mod tracing;
|
||||
pub(crate) mod types;
|
||||
|
||||
static FRECENCY: LazyLock<RwLock<Option<FrecencyTracker>>> = LazyLock::new(|| RwLock::new(None));
|
||||
static FILE_PICKER: LazyLock<RwLock<Option<FilePicker>>> = LazyLock::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)
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
|
||||
picker.trigger_rescan()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_cached_files(_: &Lua, _: ()) -> LuaResult<Vec<FileItem>> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
Ok(picker.get_cached_files())
|
||||
}
|
||||
|
||||
pub fn fuzzy_search_files(
|
||||
_: &Lua,
|
||||
(query, max_results, max_threads, current_file): (String, usize, usize, Option<String>),
|
||||
) -> LuaResult<SearchResult> {
|
||||
let time = std::time::Instant::now();
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
::tracing::debug!("Fuzzy search started: {:?}", time.elapsed());
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
|
||||
let results = picker.fuzzy_search(&query, max_results, max_threads, current_file.as_ref());
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn access_file(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
if let Some(ref tracker) = *frecency {
|
||||
let file_key = FileKey { path: file_path };
|
||||
tracker.track_access(&file_key)?;
|
||||
}
|
||||
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::InvalidPath("File picker not initialized".to_string()))?;
|
||||
let progress = picker.get_scan_progress();
|
||||
|
||||
let table = lua.create_table()?;
|
||||
table.set("total_files", progress.total_files)?;
|
||||
table.set("scanned_files", progress.scanned_files)?;
|
||||
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::InvalidPath("File picker not initialized".to_string()))?;
|
||||
Ok(picker.is_scan_active())
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<Vec<FileItem>> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
|
||||
Ok(picker.refresh_git_status())
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
picker.stop_background_monitor();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
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::InvalidPath("File picker not initialized".to_string()))?;
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms.unwrap_or(5000)); // Default 5s timeout
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
while picker.is_scan_active() && start_time.elapsed() < timeout {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
Ok(!picker.is_scan_active())
|
||||
}
|
||||
|
||||
pub fn init_tracing(
|
||||
_: &Lua,
|
||||
(log_file_path, log_level): (String, Option<String>),
|
||||
) -> LuaResult<String> {
|
||||
let level = log_level.unwrap_or_else(|| "info".to_string());
|
||||
crate::tracing::init_tracing(&log_file_path, &level)
|
||||
.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("scan_files", lua.create_function(scan_files)?)?;
|
||||
exports.set("get_cached_files", lua.create_function(get_cached_files)?)?;
|
||||
exports.set(
|
||||
"fuzzy_search_files",
|
||||
lua.create_function(fuzzy_search_files)?,
|
||||
)?;
|
||||
exports.set("access_file", lua.create_function(access_file)?)?;
|
||||
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)?,
|
||||
)?;
|
||||
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)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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
|
||||
};
|
||||
|
||||
let current_dir = if let Some(parent) = std::path::Path::new(current_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let candidate_dir = if let Some(parent) = std::path::Path::new(candidate_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if current_dir == candidate_dir {
|
||||
return 0; // Same directory, no penalty
|
||||
}
|
||||
|
||||
let current_parts: Vec<&str> = current_dir.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let candidate_parts: Vec<&str> = candidate_dir.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
let common_len = current_parts
|
||||
.iter()
|
||||
.zip(candidate_parts.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
|
||||
let current_depth_from_common = current_parts.len() - common_len;
|
||||
let candidate_depth_from_common = candidate_parts.len() - common_len;
|
||||
let total_distance = current_depth_from_common + candidate_depth_from_common;
|
||||
|
||||
if total_distance == 0 {
|
||||
return 0; // Same path
|
||||
}
|
||||
|
||||
let penalty = -(total_distance as i32 * 2);
|
||||
|
||||
penalty.max(-20)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_calculate_distance_penalty() {
|
||||
assert_eq!(calculate_distance_penalty(None, "/path/to/file.txt"), 0);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("/path/to/current/file.txt"),
|
||||
"/path/to/current/other.txt"
|
||||
),
|
||||
0
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(Some("/path/to/current/file.txt"), "/path/to/file.txt"),
|
||||
-2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("/path/to/current/file.txt"),
|
||||
"/path/to/other/file.txt"
|
||||
),
|
||||
-4
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("/path/to/current/file.txt"),
|
||||
"/path/to/another/dir/file.txt"
|
||||
),
|
||||
-6
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(Some("/a/b/c/d/file.txt"), "/x/y/z/w/file.txt"),
|
||||
-16
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(Some("/file1.txt"), "/file2.txt"),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
use crate::{
|
||||
git::is_modified_status,
|
||||
path_utils::calculate_distance_penalty,
|
||||
types::{FileItem, Score, ScoringContext},
|
||||
};
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Vec<(usize, Score)> {
|
||||
if context.query.len() < 2 {
|
||||
return score_all_by_frecency(files, context);
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let options = neo_frizbee::Options {
|
||||
prefilter: true,
|
||||
max_typos: Some(context.max_typos),
|
||||
sort: false,
|
||||
};
|
||||
|
||||
let haystack: Vec<&str> = files.iter().map(|f| f.relative_path.as_str()).collect();
|
||||
tracing::debug!(
|
||||
"Starting fuzzy search for query '{}' in {} files",
|
||||
context.query,
|
||||
haystack.len()
|
||||
);
|
||||
let path_matches =
|
||||
neo_frizbee::match_list_parallel(context.query, &haystack, options, context.max_threads);
|
||||
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_in_haystack as usize)
|
||||
.map(|f| f.file_name.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut filename_matches = neo_frizbee::match_list_parallel(
|
||||
context.query,
|
||||
&haystack_of_filenames,
|
||||
options,
|
||||
context.max_threads,
|
||||
);
|
||||
|
||||
filename_matches.par_sort_by_key(|m| m.index_in_haystack);
|
||||
let mut next_filename_match_index = 0;
|
||||
let mut results: Vec<_> = path_matches
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, neo_frizbee_match)| {
|
||||
let file_idx = neo_frizbee_match.index_in_haystack as usize;
|
||||
let file = &files[file_idx];
|
||||
|
||||
let base_score = neo_frizbee_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.map(|s| s.as_str()),
|
||||
&file.relative_path,
|
||||
);
|
||||
|
||||
let filename_match = filename_matches
|
||||
.get(next_filename_match_index)
|
||||
.and_then(|m| {
|
||||
if m.index_in_haystack == index as u32 {
|
||||
next_filename_match_index += 1;
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
tracing::debug!(filename_match = ?filename_match, "Filename match for file {}", index);
|
||||
|
||||
let mut has_special_filename_bonus = false;
|
||||
let filename_bonus = match filename_match {
|
||||
Some(filename_match) if filename_match.exact => {
|
||||
base_score / 5 * 2 // 40% bonus for exact filename match
|
||||
}
|
||||
Some(_) => base_score / 5, // 20% bonus for fuzzy filename match
|
||||
None if is_special_entry_point_file(&file.file_name) => {
|
||||
// 18% bonus special filename just as much as exact path
|
||||
// but a little bit less to give preference to the actual file if present
|
||||
has_special_filename_bonus = true;
|
||||
base_score * 18 / 100
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let total = base_score
|
||||
.saturating_add(frecency_boost)
|
||||
.saturating_add(distance_penalty)
|
||||
.saturating_add(filename_bonus);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
base_score,
|
||||
filename_bonus,
|
||||
special_filename_bonus: if has_special_filename_bonus {
|
||||
filename_bonus
|
||||
} else {
|
||||
0
|
||||
},
|
||||
frecency_boost,
|
||||
distance_penalty,
|
||||
match_type: match filename_match {
|
||||
Some(filename_match) if filename_match.exact => "exact_filename",
|
||||
Some(_) => "fuzzy_filename",
|
||||
None => "fuzzy_path",
|
||||
},
|
||||
};
|
||||
|
||||
(file_idx, score)
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.par_sort_by(|a, b| b.1.total.cmp(&a.1.total));
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// 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(files: &[FileItem], context: &ScoringContext) -> Vec<(usize, Score)> {
|
||||
files
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, file)| {
|
||||
let total_frecency_score = file.access_frecency_score as i32
|
||||
+ (file.modification_frecency_score as i32).saturating_mul(4);
|
||||
|
||||
let distance_penalty = calculate_distance_penalty(
|
||||
context.current_file.map(|x| x.as_str()),
|
||||
&file.relative_path,
|
||||
);
|
||||
|
||||
let total = total_frecency_score
|
||||
.saturating_add(distance_penalty)
|
||||
.saturating_add(calculate_file_bonus(file, context));
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
base_score: 0,
|
||||
filename_bonus: 0,
|
||||
special_filename_bonus: 0,
|
||||
frecency_boost: total_frecency_score,
|
||||
distance_penalty,
|
||||
match_type: "frecency",
|
||||
};
|
||||
|
||||
(idx, score)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_file_bonus(file: &FileItem, context: &ScoringContext) -> i32 {
|
||||
let mut bonus = 0i32;
|
||||
|
||||
if let Some(current) = context.current_file {
|
||||
if file.relative_path == *current {
|
||||
bonus -= match file.git_status {
|
||||
Some(status) if is_modified_status(status) => 150,
|
||||
_ => 300,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
bonus
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user