Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e50f8df80 | |||
| 736c41ecd6 | |||
| 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 |
+3
-8
@@ -10,12 +10,7 @@ rustflags = ["-C", "target-feature=-crt-static"]
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
rustflags = ["-C", "target-feature=-crt-static"]
|
||||
|
||||
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
|
||||
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
|
||||
[target.aarch64-linux-android]
|
||||
rustflags = [
|
||||
"-C",
|
||||
"linker=aarch64-linux-android-clang",
|
||||
"-C",
|
||||
"link-args=-rdynamic",
|
||||
"-C",
|
||||
"default-linker-libraries",
|
||||
]
|
||||
rustflags = ["-C", "link-args=-rdynamic"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Lua E2E Tests
|
||||
name: e2e Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,18 +12,21 @@ env:
|
||||
|
||||
jobs:
|
||||
lua-tests:
|
||||
name: Lua E2E (${{ matrix.os }})
|
||||
name: e2e (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
# e2e tests could be flaky on CI so we do not block release creation if they failed
|
||||
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
# - os: ubuntu-latest TODO uncomment once bun stop crashing
|
||||
- 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
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Build Rust binary (Windows)
|
||||
if: matrix.target
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
|
||||
- name: Copy binary to target/release (Windows)
|
||||
if: matrix.target
|
||||
@@ -49,9 +52,26 @@ jobs:
|
||||
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
|
||||
run: cargo build --release -p fff-nvim --features zlob
|
||||
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
@@ -65,6 +85,8 @@ jobs:
|
||||
|
||||
- name: Run Lua tests
|
||||
shell: bash
|
||||
run: |
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
run: make test-lua
|
||||
|
||||
- name: Run bun tests
|
||||
shell: bash
|
||||
run: make test-bun
|
||||
@@ -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/
|
||||
+303
-19
@@ -2,7 +2,7 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, feat/mcp-ai]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -36,6 +36,12 @@ jobs:
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
@@ -64,7 +70,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -73,22 +79,40 @@ jobs:
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -110,39 +134,62 @@ jobs:
|
||||
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_c.so
|
||||
npm_package: fff-bun-linux-x64-gnu
|
||||
lib_filename: libfff_c.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_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_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_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_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_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_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_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:
|
||||
@@ -154,7 +201,120 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- 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-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Prepare npm package
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy the built binary into the platform npm package directory
|
||||
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: c-lib-${{ matrix.target }}
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
- name: Upload npm package artifact
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
path: packages/${{ matrix.npm_package }}/
|
||||
|
||||
build-mcp:
|
||||
name: Build MCP ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
## Linux builds (using cargo-zigbuild)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/fff-mcp
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/fff-mcp
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/fff-mcp
|
||||
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff-mcp.exe
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -165,32 +325,38 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
|
||||
|
||||
- name: Upload artifacts
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: c-lib-${{ matrix.target }}
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
name: mcp-${{ matrix.target }}
|
||||
path: fff-mcp-${{ matrix.target }}*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: [build-nvim, build-c]
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
# do not create releases on the forks (no permissions)
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dmtrKovalenko'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -231,6 +397,24 @@ jobs:
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Flatten MCP artifacts
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
for dir in mcp-*/; do
|
||||
for file in "$dir"*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
mv "$file" "./$filename"
|
||||
fi
|
||||
done
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Remove npm package artifacts from release binaries
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
rm -rf npm-*
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
@@ -267,11 +451,111 @@ jobs:
|
||||
## C FFI Library (for Bun/Node/Python)
|
||||
- `c-lib-{target}.so` / `.dylib` / `.dll` - C FFI library
|
||||
|
||||
## MCP Server
|
||||
- `fff-mcp-{target}` - MCP server binary
|
||||
|
||||
Install with:
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
|
||||
```
|
||||
|
||||
npm-publish:
|
||||
name: Publish npm packages
|
||||
needs: [build-c]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/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]
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
# comments doesn't work on forks
|
||||
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim' && github.event.pull_request.user.login == 'dmtrKovalenko'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
# Zig is required to compile zlob
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -37,7 +37,9 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose --workspace --exclude fff-nvim
|
||||
run: |
|
||||
cargo test --verbose -p fff-core -p fff-query-parser -p fff-c --features zlob
|
||||
cargo test --verbose -p grep-searcher
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
@@ -60,7 +62,7 @@ jobs:
|
||||
|
||||
# Zig is required to compile zlob
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -71,4 +73,6 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
run: |
|
||||
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
|
||||
cargo clippy -p grep-searcher -- -D warnings
|
||||
|
||||
@@ -13,3 +13,12 @@ result
|
||||
big-repo/
|
||||
# all the perf like utility files
|
||||
*.data
|
||||
node_modules/
|
||||
|
||||
dist/
|
||||
scripts/benchmark-results/
|
||||
|
||||
# Native binaries (downloaded at install)
|
||||
*.dylib
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
+25
@@ -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,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+602
-107
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -2,35 +2,45 @@
|
||||
members = [
|
||||
"crates/fff-c",
|
||||
"crates/fff-core",
|
||||
"crates/fff-mcp",
|
||||
"crates/fff-nvim",
|
||||
"crates/fff-query-parser",
|
||||
"crates/fff-searcher",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
bindet = "0.3"
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
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.7.2" }
|
||||
neo_frizbee = "0.8.2"
|
||||
notify = "8.1.0"
|
||||
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"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
PLENARY_DIR ?= ../plenary.nvim
|
||||
|
||||
.PHONY: build test test-rust test-lua test-setup
|
||||
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
cargo build --release --features zlob
|
||||
|
||||
test-setup:
|
||||
@if [ ! -d "$(PLENARY_DIR)" ]; then \
|
||||
@@ -12,17 +12,44 @@ test-setup:
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --verbose --workspace --exclude fff-nvim
|
||||
cargo test --workspace --features zlob
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
|
||||
test: test-rust test-lua
|
||||
prepare-bun: build
|
||||
mkdir -p packages/fff-bun/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null; \
|
||||
true
|
||||
@# Re-sign on macOS: cp can invalidate ad-hoc code signatures
|
||||
@if [ "$$(uname)" = "Darwin" ] && command -v codesign >/dev/null 2>&1; then \
|
||||
codesign --sign - packages/fff-bun/bin/libfff_c.dylib 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
test-bun: prepare-bun
|
||||
cd packages/fff-bun && bun test src/
|
||||
|
||||
test: test-rust test-lua test-bun
|
||||
|
||||
format-rust:
|
||||
cargo fmt --all
|
||||
format-lua:
|
||||
stylua .
|
||||
format-ts:
|
||||
bun format
|
||||
|
||||
format: format-rust format-lua
|
||||
format: format-rust format-lua format-ts
|
||||
|
||||
lint-rust:
|
||||
cargo clippy --workspace --features zlob -- -D warnings
|
||||
lint-lua:
|
||||
~/.luarocks/bin/luacheck .
|
||||
lint-ts:
|
||||
bun lint
|
||||
|
||||
lint: lint-rust lint-lua lint-ts
|
||||
|
||||
check: format lint
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<p align="center">
|
||||
<h2 align="center">FFF.nvim</h2>
|
||||
<h1 align="center">FFF</h1>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Finally a smart fuzzy file picker for neovim.
|
||||
<a href="#mcp"><strong>AI agents (MCP)</strong></a> | <a href="#neovim-guide"><strong>Neovim users</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<i>A fast file search for your AI and neovim, with memory built-in</i>
|
||||
</p>
|
||||
|
||||
<p align="center" style="text-decoration: none; border: none;">
|
||||
@@ -14,35 +18,44 @@
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none"> <img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
|
||||
</p>
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
---
|
||||
|
||||
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
|
||||
**FFF** stands for ~~freakin fast fuzzy file finder~~ (pick 3) and it is an opinionated fuzzy file picker for your AI agent and Neovim. Just for file search, but we do the file search really fff well.
|
||||
|
||||
## Features
|
||||
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping with a strong focus on performance and useful search results. For humans - provides an unbelievable typo-resistant experience, for AI agents - implements the fastest file search with additional free memory suggesting the best search results based on various factors like frecency, git status, file size, definition matches, and more.
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- [Typo resistant fuzzy search](https://github.com/saghen/frizbee)
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
## MCP
|
||||
|
||||
## Installation
|
||||
FFF is an amazing way to reduce the time and tokens by giving your AI agent a bit of memory built-in to their file search tools. It makes your AI harness to find the code faster and spend less tokens by doing less roundtrips and reading less useless files.
|
||||
|
||||
> [!NOTE]
|
||||
> Although we'll try to make sure to keep 100% backward compatibility, by using you should understand that silly bugs and breaking changes may happen.
|
||||
> And also we hope for your contributions and feedback to make this plugin ideal for everyone.
|
||||

|
||||
|
||||
### Prerequisites
|
||||
You can install FFF as a dependency for your AI agent using a simple bash script:
|
||||
|
||||
FFF.nvim requires:
|
||||
```bash
|
||||
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
|
||||
```
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
> The installation script is here [./install-fff.sh](./install-fff.sh) if you want to review it before running.
|
||||
|
||||
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
|
||||
Here is an example addition to `CLAUDE.md` that works perfectly:
|
||||
|
||||
```sh
|
||||
# CLAUDE.md
|
||||
For any file search or grep in the current git indexed directory use fff tools
|
||||
```
|
||||
|
||||
## Neovim guide
|
||||
|
||||
Here is some demo on the linux repository (100k files, 8GB) but you better fill it yourself and see the magic
|
||||
|
||||
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
|
||||
### Installation
|
||||
|
||||
FFF.nvim requires neovim 0.10.0 or higher
|
||||
|
||||
#### lazy.nvim
|
||||
|
||||
```lua
|
||||
@@ -69,7 +82,26 @@ FFF.nvim requires:
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
}
|
||||
},
|
||||
{
|
||||
"fg",
|
||||
function() require('fff').live_grep() end,
|
||||
desc = 'LiFFFe grep',
|
||||
},
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -122,6 +154,10 @@ require('fff').setup({
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
@@ -137,6 +173,7 @@ require('fff').setup({
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -156,25 +193,29 @@ require('fff').setup({
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -199,6 +240,14 @@ require('fff').setup({
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
@@ -209,24 +258,35 @@ require('fff').setup({
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
enabled = false, -- Show file info panel in preview
|
||||
show_scores = false, -- Show scores inline in the UI
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
}
|
||||
})
|
||||
},
|
||||
-- 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
|
||||
@@ -234,19 +294,19 @@ require('fff').setup({
|
||||
#### Available Methods
|
||||
|
||||
```lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repository
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file list
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
```
|
||||
|
||||
just jump to the definition and see what other APIs are exposed we have a plenty
|
||||
|
||||
#### Commands
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
@@ -254,10 +314,6 @@ FFF.nvim provides several commands for interacting with the file picker:
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
#### Multiline Paste Support
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Toggle scoring information display:
|
||||
@@ -273,11 +329,92 @@ Select multiple files and send them to Neovim's quickfix list (keymaps are confi
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
#### Live Grep Search Modes
|
||||
|
||||
Live grep supports three search modes, cycled with `<S-Tab>`:
|
||||
|
||||
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
|
||||
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
|
||||
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
|
||||
|
||||
The current mode is shown on the right side of the input field (e.g., `plain`, `regex`, `fuzzy`) with color-coded highlighting.
|
||||
|
||||
You can customize which modes are available and their cycling order globally in your configuration, or per-call when invoking `live_grep()`.
|
||||
|
||||
**Global configuration:**
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
grep = {
|
||||
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Per-call configuration:**
|
||||
|
||||
```lua
|
||||
-- Only fuzzy and plain modes for this specific grep
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Single mode (hides mode indicator completely)
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Pre-fill the search with an initial query
|
||||
require('fff').live_grep({ query = 'search term' })
|
||||
```
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
|
||||
|
||||
#### Constraints
|
||||
|
||||
There are a number of constraints you can use to refine your search in both grep and file search mode:
|
||||
|
||||
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
|
||||
- `test/` - any deeply nested children of any test/ dir
|
||||
- `!something` - exclude results matching something
|
||||
- `!test/`, `!git:modified` - combining with any other constraint works as negation
|
||||
- `./**/*.{rs,lua}` - any valid glob expression via [the fastest globbing library](https://github.com/dmtrKovalenko/zlob)
|
||||
|
||||
For grep only:
|
||||
|
||||
- `*.md`, `*.{c,h}` - extension filtering
|
||||
- `src/main.rs` - grep in a single file
|
||||
|
||||
In addition to that, all constraints can be combined together like:
|
||||
|
||||
```
|
||||
git:modified src/**/*.rs !src/**/mod.rs user controller
|
||||
```
|
||||
|
||||
This will find all the files that qualify the constraints and:
|
||||
|
||||
- match **both** user and controller (for file mode)
|
||||
- match "user controller" (for grep mode)
|
||||
|
||||
#### Cross-Mode Suggestions
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search mode and displays the results as suggestions:
|
||||
|
||||
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
|
||||
- **Grep search with no matches** → shows suggested **file name matches** for the same query
|
||||
|
||||
Suggestions are clearly labeled with a "No results found. Suggested ..." banner (highlighted with `hl.suggestion_header`). You can navigate and select suggestion items just like normal results — selecting a grep suggestion will open the file at the matching line.
|
||||
|
||||
#### Git Status Highlighting
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
|
||||
|
||||
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
|
||||
|
||||
```lua
|
||||
hl = {
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
@@ -292,6 +429,7 @@ hl = {
|
||||
**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 = {
|
||||
@@ -311,6 +449,7 @@ require('fff').setup({
|
||||
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' })
|
||||
@@ -354,41 +493,8 @@ Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
```vim
|
||||
```
|
||||
:FFFOpenLog
|
||||
```
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
|
||||
|
||||
#### Common Issues
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it's properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"files": {
|
||||
"includes": ["packages/**/*.ts", "!packages/*/dist"],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 90
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
},
|
||||
},
|
||||
"packages/fff-bun": {
|
||||
"name": "@ff-labs/fff-bun",
|
||||
"version": "0.1.37",
|
||||
"bin": {
|
||||
"fff": "./scripts/cli.ts",
|
||||
"fff-demo": "./examples/search.ts",
|
||||
"fff-grep": "./examples/grep.ts",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-x64": "0.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0",
|
||||
},
|
||||
},
|
||||
"packages/fff-mcp": {
|
||||
"name": "@ff-labs/fff-mcp",
|
||||
"version": "0.1.0",
|
||||
"bin": {
|
||||
"fff-mcp": "./src/index.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@ff-labs/fff-bun": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"zod": "^3.24.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ff-labs/fff-bun",
|
||||
],
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@workspace:packages/fff-bun"],
|
||||
|
||||
"@ff-labs/fff-mcp": ["@ff-labs/fff-mcp@workspace:packages/fff-mcp"],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA=="],
|
||||
|
||||
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bun": ["bun@1.3.10", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.10", "@oven/bun-darwin-x64": "1.3.10", "@oven/bun-darwin-x64-baseline": "1.3.10", "@oven/bun-linux-aarch64": "1.3.10", "@oven/bun-linux-aarch64-musl": "1.3.10", "@oven/bun-linux-x64": "1.3.10", "@oven/bun-linux-x64-baseline": "1.3.10", "@oven/bun-linux-x64-musl": "1.3.10", "@oven/bun-linux-x64-musl-baseline": "1.3.10", "@oven/bun-windows-aarch64": "1.3.10", "@oven/bun-windows-x64": "1.3.10", "@oven/bun-windows-x64-baseline": "1.3.10" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ license = "MIT"
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[dependencies]
|
||||
mimalloc.workspace = true
|
||||
once_cell.workspace = true
|
||||
tracing.workspace = true
|
||||
git2.workspace = true
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
//! These types use #[repr(C)] for C ABI compatibility and implement
|
||||
//! serde traits for JSON serialization.
|
||||
|
||||
use std::ffi::{CString, c_char};
|
||||
use std::ffi::{CString, c_char, c_void};
|
||||
use std::ptr;
|
||||
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FileItem, Location, Score, SearchResult};
|
||||
use fff_core::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Result type returned by all FFI functions
|
||||
@@ -20,6 +20,8 @@ pub struct FffResult {
|
||||
pub data: *mut c_char,
|
||||
/// Error message on failure (null-terminated string, caller must free)
|
||||
pub error: *mut c_char,
|
||||
/// Opaque handle pointer (used by fff_create to return the instance)
|
||||
pub handle: *mut c_void,
|
||||
}
|
||||
|
||||
impl FffResult {
|
||||
@@ -29,6 +31,7 @@ impl FffResult {
|
||||
success: true,
|
||||
data: ptr::null_mut(),
|
||||
error: ptr::null_mut(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -38,6 +41,17 @@ impl FffResult {
|
||||
success: true,
|
||||
data: CString::new(data).unwrap_or_default().into_raw(),
|
||||
error: ptr::null_mut(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a successful result carrying an opaque instance handle.
|
||||
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
|
||||
Box::into_raw(Box::new(FffResult {
|
||||
success: true,
|
||||
data: ptr::null_mut(),
|
||||
error: ptr::null_mut(),
|
||||
handle,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -47,6 +61,7 @@ impl FffResult {
|
||||
success: false,
|
||||
data: ptr::null_mut(),
|
||||
error: CString::new(error).unwrap_or_default().into_raw(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -63,6 +78,14 @@ pub struct InitOptions {
|
||||
/// 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,
|
||||
/// AI mode: automatically track frecency for all file modifications detected
|
||||
/// by the background watcher (optional, defaults to false)
|
||||
#[serde(default)]
|
||||
pub ai_mode: bool,
|
||||
}
|
||||
|
||||
/// Search options (JSON-deserializable)
|
||||
@@ -101,6 +124,7 @@ pub struct FileItemJson {
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: String,
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
impl FileItemJson {
|
||||
@@ -115,6 +139,7 @@ impl FileItemJson {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,3 +244,161 @@ impl SearchResultJson {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-grep (Aho-Corasick multi-pattern) types
|
||||
// ============================================================================
|
||||
|
||||
/// Multi-grep search options (JSON-deserializable)
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct MultiGrepOptionsJson {
|
||||
/// Patterns to search (OR logic — matches lines containing any pattern)
|
||||
pub patterns: Vec<String>,
|
||||
/// Optional constraint query like "*.rs" or "/src/"
|
||||
pub constraints: Option<String>,
|
||||
/// Maximum file size to search (bytes, default: 10MB)
|
||||
pub max_file_size: Option<u64>,
|
||||
/// Maximum matches per file (default: 0 = unlimited)
|
||||
pub max_matches_per_file: Option<usize>,
|
||||
/// Smart case: case-insensitive if all patterns are lowercase (default: true)
|
||||
pub smart_case: Option<bool>,
|
||||
/// File-based pagination offset (default: 0)
|
||||
pub file_offset: Option<usize>,
|
||||
/// Maximum matches to return per page (default: 50)
|
||||
pub page_limit: Option<usize>,
|
||||
/// Time budget in milliseconds, 0 = unlimited (default: 0)
|
||||
pub time_budget_ms: Option<u64>,
|
||||
/// Number of context lines before each match (default: 0)
|
||||
pub before_context: Option<usize>,
|
||||
/// Number of context lines after each match (default: 0)
|
||||
pub after_context: Option<usize>,
|
||||
/// Whether to classify matches as definition lines (default: false)
|
||||
pub classify_definitions: Option<bool>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grep (live search) types
|
||||
// ============================================================================
|
||||
|
||||
/// 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>,
|
||||
/// Number of context lines before each match (default: 0)
|
||||
pub before_context: Option<usize>,
|
||||
/// Number of context lines after each match (default: 0)
|
||||
pub after_context: Option<usize>,
|
||||
/// Whether to classify matches as definition lines (default: false)
|
||||
pub classify_definitions: Option<bool>,
|
||||
}
|
||||
|
||||
/// A single grep match for JSON serialization
|
||||
#[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>,
|
||||
/// Whether the matched line is a code definition (struct, fn, class, etc.)
|
||||
pub is_definition: bool,
|
||||
/// Lines before the match (context)
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub context_before: Vec<String>,
|
||||
/// Lines after the match (context)
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub context_after: Vec<String>,
|
||||
}
|
||||
|
||||
impl GrepMatchJson {
|
||||
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,
|
||||
is_definition: m.is_definition,
|
||||
context_before: m.context_before.clone(),
|
||||
context_after: m.context_after.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+539
-272
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@ crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
@@ -23,31 +26,40 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
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 }
|
||||
aho-corasick = "1"
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
memmap2 = { workspace = true }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
notify-debouncer-full = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
toml = "0.8"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { version = "1.2.8" }
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
zlob = { workspace = true, optional = true }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
+42
-12
@@ -1,16 +1,46 @@
|
||||
fn main() {
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// This is needed because Zig-compiled static libraries (zlob) don't emit
|
||||
// /DEFAULTLIB directives for the MSVC CRT. Without this, symbols like
|
||||
// strcmp, memcpy, memchr etc. from vendored C libraries (libgit2, lmdb)
|
||||
// are unresolved when linking the cdylib.
|
||||
//
|
||||
// We link both msvcrt (classic CRT) and ucrt (Universal CRT where memchr,
|
||||
// strcmp etc. live on newer MSVC/ARM64 targets).
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
|
||||
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
}
|
||||
} else if std::env::var("CI").is_ok() {
|
||||
// CI must always build with zlob for production-quality binaries.
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"CI detected but Zig is not installed. \
|
||||
Please install Zig and build with `--features zlob`."
|
||||
);
|
||||
}
|
||||
panic!(
|
||||
"CI detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob`."
|
||||
);
|
||||
} else {
|
||||
// Hint: if Zig is available but the zlob feature wasn't enabled,
|
||||
// let the developer know they can get faster glob matching.
|
||||
if zig_available() {
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the system for a working Zig installation.
|
||||
fn zig_available() -> bool {
|
||||
std::process::Command::new("zig")
|
||||
.arg("version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
use crate::FILE_PICKER;
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::file_picker::{FFFMode, FilePicker};
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify_debouncer_full::{
|
||||
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
|
||||
};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::{Level, error, info, warn};
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
|
||||
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
|
||||
|
||||
pub struct BackgroundWatcher {
|
||||
debouncer: Arc<Mutex<Option<Debouncer>>>,
|
||||
@@ -22,15 +20,27 @@ pub struct BackgroundWatcher {
|
||||
|
||||
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
|
||||
/// Minimum seconds between frecency tracks of the same file in AI mode.
|
||||
/// Prevents score inflation from rapid burst edits by AI agents.
|
||||
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
|
||||
|
||||
impl BackgroundWatcher {
|
||||
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
|
||||
pub fn new(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing background watcher for path: {}",
|
||||
base_path.display()
|
||||
"Initializing background watcher for path: {}, mode: {:?}",
|
||||
base_path.display(),
|
||||
mode,
|
||||
);
|
||||
|
||||
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
|
||||
let debouncer =
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
Ok(Self {
|
||||
@@ -41,31 +51,81 @@ impl BackgroundWatcher {
|
||||
fn create_debouncer(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) -> Result<Debouncer, Error> {
|
||||
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
|
||||
// files that could be git ignored, we have to property differentiate those and if
|
||||
// the file was edited through a
|
||||
let config = Config::default().with_follow_symlinks(false);
|
||||
|
||||
let git_workdir_for_handler = git_workdir.clone();
|
||||
let mut debouncer = new_debouncer_opt(
|
||||
DEBOUNCE_TIMEOUT,
|
||||
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
|
||||
{
|
||||
move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
handle_debounced_events(events, &git_workdir);
|
||||
handle_debounced_events(
|
||||
events,
|
||||
&git_workdir_for_handler,
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
Err(errors) => {
|
||||
error!("File watcher errors: {:?}", errors);
|
||||
}
|
||||
}
|
||||
},
|
||||
RecommendedCache::new(),
|
||||
// There is an issue with recommended cache implementation on macos
|
||||
// it keeps track of all the files added to the watcher which is not a problem
|
||||
// for us because any rename to the file will anyway require the removing from the
|
||||
// ordedred index and adding it back with the new name
|
||||
NoCache::new(),
|
||||
config,
|
||||
)?;
|
||||
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
info!("File watcher initizlieed for path: {}", base_path.display());
|
||||
// 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)
|
||||
}
|
||||
@@ -92,8 +152,14 @@ impl Drop for BackgroundWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
|
||||
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
|
||||
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency), level = Level::DEBUG)]
|
||||
fn handle_debounced_events(
|
||||
events: Vec<DebouncedEvent>,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) {
|
||||
// this will be called very often, we have to minimiy the lock time for file picker
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
let mut need_full_rescan = false;
|
||||
@@ -116,6 +182,18 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
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) {
|
||||
@@ -131,14 +209,30 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
need_full_git_rescan = true;
|
||||
}
|
||||
|
||||
if !should_include_file(path, &repo) {
|
||||
if is_git_file(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !path.exists() {
|
||||
// Use a combination of event kind and filesystem state to decide
|
||||
// whether a path is an addition/modification or a removal.
|
||||
//
|
||||
// We cannot rely on `path.exists()` alone because:
|
||||
// - A freshly created file might not be visible yet (race).
|
||||
// - macOS FSEvents uses Modify(Name(Any)) for both rename-in
|
||||
// and rename-out, so we must stat the path to disambiguate.
|
||||
//
|
||||
// We cannot rely on event kind alone because:
|
||||
// - Remove events are not always emitted (macOS often sends
|
||||
// Modify(Name(Any)) instead of Remove).
|
||||
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
|
||||
|
||||
if is_removal || !path.exists() {
|
||||
paths_to_remove.push(path.as_path());
|
||||
} else {
|
||||
paths_to_add_or_modify.push(path.as_path());
|
||||
// For additions/modifications, still filter gitignored files.
|
||||
if should_include_file(path, &repo) {
|
||||
paths_to_add_or_modify.push(path.as_path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +254,7 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
|
||||
if need_full_rescan {
|
||||
info!(?affected_paths_count, "Triggering full rescan");
|
||||
trigger_full_rescan();
|
||||
trigger_full_rescan(shared_picker, shared_frecency);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -176,93 +270,164 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
paths_to_add_or_modify.len()
|
||||
);
|
||||
|
||||
// Apply file index updates (add/remove) unconditionally — these must
|
||||
// happen even when there is no git repository.
|
||||
let files_to_update_git_status =
|
||||
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
|
||||
debug!(
|
||||
"Applying file index changes: {} to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
);
|
||||
|
||||
let apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
|
||||
for path in &paths_to_remove {
|
||||
let removed = picker.remove_file_by_path(path);
|
||||
debug!("remove_file_by_path({:?}) -> {}", path, removed);
|
||||
}
|
||||
|
||||
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in &paths_to_add_or_modify {
|
||||
let result = picker.on_create_or_modify(path);
|
||||
match result {
|
||||
Some(file) => {
|
||||
debug!(
|
||||
"on_create_or_modify({:?}) -> Some({})",
|
||||
path,
|
||||
file.path.display()
|
||||
);
|
||||
files_to_update.push(file.path.clone());
|
||||
}
|
||||
None => {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"apply_changes complete: {} files to update git status",
|
||||
files_to_update.len()
|
||||
);
|
||||
files_to_update
|
||||
};
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock");
|
||||
return;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized");
|
||||
return;
|
||||
};
|
||||
apply_changes(picker)
|
||||
} else {
|
||||
debug!("No file index changes to apply");
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// AI mode: auto-track frecency for all modified/created files.
|
||||
// Uses a 5-minute cooldown per file to prevent score inflation from rapid
|
||||
// burst edits (AI agents often edit the same file many times in minutes).
|
||||
// This runs after apply_changes so the picker write lock is released.
|
||||
if mode.is_ai() && !paths_to_add_or_modify.is_empty() {
|
||||
let mut tracked_count = 0usize;
|
||||
if let Ok(frecency_guard) = shared_frecency.read()
|
||||
&& let Some(ref frecency) = *frecency_guard
|
||||
{
|
||||
for path in &paths_to_add_or_modify {
|
||||
// Skip if this file was tracked less than 5 minutes ago
|
||||
let should_track = match frecency.seconds_since_last_access(path) {
|
||||
Ok(Some(secs)) => secs >= AI_MODE_COOLDOWN_SECS,
|
||||
Ok(None) => true, // Never tracked before
|
||||
Err(_) => true, // DB error, track anyway
|
||||
};
|
||||
if !should_track {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = frecency.track_access(path) {
|
||||
error!("Failed to track frecency for {:?}: {:?}", path, e);
|
||||
} else {
|
||||
tracked_count += 1;
|
||||
}
|
||||
}
|
||||
if tracked_count > 0 {
|
||||
info!("AI mode: tracked frecency for {} files", tracked_count);
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-memory frecency scores for tracked files
|
||||
if tracked_count > 0
|
||||
&& let Ok(mut picker_guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *picker_guard
|
||||
&& let Ok(frecency_guard) = shared_frecency.read()
|
||||
&& let Some(ref frecency) = *frecency_guard
|
||||
{
|
||||
for path in &paths_to_add_or_modify {
|
||||
let _ = picker.update_single_file_frecency(path, frecency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Git status updates require a repository.
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
info!("No git repo, skipping git status updates");
|
||||
debug!("No git repo available, skipping git status updates");
|
||||
return;
|
||||
};
|
||||
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
|
||||
if let Err(e) = FilePicker::refresh_git_status_global() {
|
||||
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
|
||||
if let Err(e) = result {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !files_to_update_git_status.is_empty() {
|
||||
info!(
|
||||
"Fetching git status for {} files",
|
||||
files_to_update_git_status.len()
|
||||
);
|
||||
|
||||
let files_to_update_git_status = {
|
||||
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
|
||||
error!("Failed to acquire file picker write lock");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(ref mut picker) = *file_picker_guard else {
|
||||
error!("File picker not initialized");
|
||||
return;
|
||||
};
|
||||
|
||||
// Apply file removals
|
||||
for path in paths_to_remove {
|
||||
picker.remove_file_by_path(path);
|
||||
}
|
||||
|
||||
// Apply file additions/modifications and collect paths for git status update
|
||||
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in paths_to_add_or_modify {
|
||||
if let Some(file) = picker.on_create_or_modify(path) {
|
||||
files_to_update_git_status.push(file.path.clone());
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to query git status");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
files_to_update_git_status
|
||||
};
|
||||
|
||||
info!(
|
||||
"Fetching git status for {} files",
|
||||
files_to_update_git_status.len()
|
||||
);
|
||||
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to query git statue");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// only lock the picker for theshortest possitble time
|
||||
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
{
|
||||
if let Err(e) = picker.update_git_statuses(status) {
|
||||
error!("Failed to update git statuses: {:?}", e);
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
if let Err(e) = picker.update_git_statuses(status, shared_frecency) {
|
||||
error!("Failed to update git statuses: {:?}", e);
|
||||
} else {
|
||||
info!("Successfully updated git statuses in picker");
|
||||
}
|
||||
} else {
|
||||
info!("Successfully updated git statuses in picker");
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
}
|
||||
} else {
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_full_rescan() {
|
||||
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
|
||||
info!("Triggering full filesystem rescan");
|
||||
|
||||
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
|
||||
// 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) = *file_picker_guard else {
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized, cannot trigger rescan");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = picker.trigger_rescan() {
|
||||
if let Err(e) = picker.trigger_rescan(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
} else {
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
@@ -270,12 +435,17 @@ fn trigger_full_rescan() {
|
||||
}
|
||||
|
||||
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
if !path.is_file() || is_git_file(path) {
|
||||
// Directories are not indexed — only regular files (and symlinks to files).
|
||||
if path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
repo.as_ref()
|
||||
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
|
||||
// If there is a git repo, respect its ignore rules.
|
||||
// If there is no repo (or the check fails), include the file.
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) != Ok(true),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -324,3 +494,72 @@ fn is_ignore_definition_path(path: &Path) -> bool {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use ahash::AHashSet;
|
||||
use fff_query_parser::{Constraint, GitStatusFilter};
|
||||
use smallvec::SmallVec;
|
||||
use zlob::{ZlobFlags, zlob_match_paths};
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
|
||||
@@ -33,6 +32,28 @@ pub trait Constrainable {
|
||||
fn git_status(&self) -> Option<git2::Status>;
|
||||
}
|
||||
|
||||
/// Check if a relative path ends with the given suffix at a `/` boundary (case-insensitive).
|
||||
///
|
||||
/// Returns `true` when the path equals the suffix or the character before the suffix
|
||||
/// in the path is `/`. This ensures partial directory-name matches are rejected.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `path_ends_with_suffix("libswscale/input.c", "libswscale/input.c")` → true (exact)
|
||||
/// - `path_ends_with_suffix("foo/libswscale/input.c", "libswscale/input.c")` → true (suffix)
|
||||
/// - `path_ends_with_suffix("xlibswscale/input.c", "libswscale/input.c")` → false (no boundary)
|
||||
#[inline]
|
||||
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
|
||||
if path.len() < suffix.len() {
|
||||
return false;
|
||||
}
|
||||
let start = path.len() - suffix.len();
|
||||
if !path[start..].eq_ignore_ascii_case(suffix) {
|
||||
return false;
|
||||
}
|
||||
// Exact match, or the character before is /
|
||||
start == 0 || path.as_bytes()[start - 1] == b'/'
|
||||
}
|
||||
|
||||
/// Check if file extension matches (without allocation)
|
||||
#[inline]
|
||||
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
|
||||
@@ -45,12 +66,14 @@ pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Check if path contains segment (without allocation)
|
||||
/// Supports both single segments ("src") and multi-segment paths ("libswscale/aarch64").
|
||||
/// For "libswscale/aarch64", checks that these appear as consecutive path components.
|
||||
#[inline]
|
||||
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
|
||||
let path_bytes = path.as_bytes();
|
||||
let segment_len = segment.len();
|
||||
|
||||
// Check segment/ at start
|
||||
// Check segment/ at start of path
|
||||
if path.len() > segment_len
|
||||
&& path_bytes.get(segment_len) == Some(&b'/')
|
||||
&& path[..segment_len].eq_ignore_ascii_case(segment)
|
||||
@@ -102,6 +125,7 @@ fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
return if negate { !result } else { result };
|
||||
}
|
||||
Constraint::PathSegment(segment) => path_contains_segment(item.relative_path(), segment),
|
||||
Constraint::FilePath(suffix) => path_ends_with_suffix(item.relative_path(), suffix),
|
||||
Constraint::GitStatus(status_filter) => match (item.git_status(), status_filter) {
|
||||
(Some(status), GitStatusFilter::Modified) => is_modified_status(status),
|
||||
(Some(status), GitStatusFilter::Untracked) => status.contains(git2::Status::WT_NEW),
|
||||
@@ -235,32 +259,8 @@ fn collect_glob_indices<'a>(
|
||||
) {
|
||||
match constraint {
|
||||
Constraint::Glob(pattern) => {
|
||||
if let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) {
|
||||
let matched_set: AHashSet<usize> =
|
||||
matches.iter().map(|s| s.as_ptr() as usize).collect();
|
||||
|
||||
let indices: AHashSet<usize> = if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
};
|
||||
results.push((is_negated, indices));
|
||||
} else {
|
||||
results.push((is_negated, AHashSet::new()));
|
||||
}
|
||||
let indices = match_glob_pattern(pattern, paths);
|
||||
results.push((is_negated, indices));
|
||||
}
|
||||
Constraint::Not(inner) => {
|
||||
collect_glob_indices(inner, paths, results, !is_negated);
|
||||
@@ -269,6 +269,66 @@ fn collect_glob_indices<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a glob pattern against a list of paths, returning the set of matching indices.
|
||||
///
|
||||
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
|
||||
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
|
||||
#[cfg(feature = "zlob")]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
|
||||
else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
|
||||
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
|
||||
|
||||
if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(glob) = globset::Glob::new(pattern) else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
let matcher = glob.compile_matcher();
|
||||
|
||||
if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -312,8 +372,78 @@ mod tests {
|
||||
// Should not match filename
|
||||
assert!(!path_contains_segment("lib/src", "src"));
|
||||
|
||||
// Multi-segment constraints
|
||||
assert!(path_contains_segment(
|
||||
"libswscale/aarch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
));
|
||||
assert!(path_contains_segment(
|
||||
"foo/libswscale/aarch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
));
|
||||
assert!(path_contains_segment(
|
||||
"foo/LibSwscale/AArch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
)); // case-insensitive
|
||||
assert!(!path_contains_segment(
|
||||
"xlibswscale/aarch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
)); // partial match at start
|
||||
assert!(!path_contains_segment(
|
||||
"foo/libswscale/aarch64x/input.S",
|
||||
"libswscale/aarch64"
|
||||
)); // partial match at end
|
||||
assert!(path_contains_segment(
|
||||
"crates/fff-core/src/grep.rs",
|
||||
"fff-core/src"
|
||||
));
|
||||
|
||||
// Edge cases
|
||||
assert!(!path_contains_segment("", "src"));
|
||||
assert!(!path_contains_segment("src", "src")); // no trailing slash
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_ends_with_suffix() {
|
||||
// Exact match
|
||||
assert!(path_ends_with_suffix(
|
||||
"libswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Suffix match at / boundary
|
||||
assert!(path_ends_with_suffix(
|
||||
"foo/libswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Deep nesting
|
||||
assert!(path_ends_with_suffix(
|
||||
"a/b/c/libswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// No boundary — partial directory name
|
||||
assert!(!path_ends_with_suffix(
|
||||
"xlibswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Case insensitive
|
||||
assert!(path_ends_with_suffix(
|
||||
"foo/LibSwscale/Input.C",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Single file name
|
||||
assert!(path_ends_with_suffix("input.c", "input.c"));
|
||||
assert!(!path_ends_with_suffix("xinput.c", "input.c"));
|
||||
|
||||
// Suffix longer than path
|
||||
assert!(!path_ends_with_suffix("input.c", "foo/input.c"));
|
||||
|
||||
// Simple path
|
||||
assert!(path_ends_with_suffix("src/main.rs", "src/main.rs"));
|
||||
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
|
||||
}
|
||||
}
|
||||
|
||||
+378
-103
@@ -5,10 +5,12 @@ 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,
|
||||
@@ -17,7 +19,37 @@ use std::sync::{
|
||||
use std::time::SystemTime;
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
use crate::{FILE_PICKER, FRECENCY};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum FFFMode {
|
||||
#[default]
|
||||
Neovim,
|
||||
Ai,
|
||||
}
|
||||
|
||||
impl FFFMode {
|
||||
pub fn is_ai(self) -> bool {
|
||||
self == FFFMode::Ai
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
|
||||
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
|
||||
#[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> {
|
||||
@@ -32,7 +64,8 @@ pub struct FuzzySearchOptions<'a> {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FileSync {
|
||||
pub files: Vec<FileItem>,
|
||||
/// Files sorted by path for binary search
|
||||
files: Vec<FileItem>,
|
||||
pub git_workdir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -44,9 +77,68 @@ impl FileSync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all files (read-only). Files are sorted by path.
|
||||
#[inline]
|
||||
fn files(&self) -> &[FileItem] {
|
||||
&self.files
|
||||
}
|
||||
|
||||
fn get_file(&self, index: usize) -> Option<&FileItem> {
|
||||
self.files.get(index)
|
||||
}
|
||||
|
||||
/// Get mutable file at index
|
||||
#[inline]
|
||||
fn get_file_mut(&mut self, index: usize) -> Option<&mut FileItem> {
|
||||
self.files.get_mut(index)
|
||||
}
|
||||
|
||||
/// Find file index by path using binary search - O(log n)
|
||||
#[inline]
|
||||
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
|
||||
self.files
|
||||
.binary_search_by(|file| file.path.as_os_str().cmp(path.as_os_str()))
|
||||
self.files.binary_search_by(|f| f.path.as_path().cmp(path))
|
||||
}
|
||||
|
||||
/// Get file count
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// Insert a file at position. Simple - no HashMap to maintain!
|
||||
fn insert_file(&mut self, position: usize, file: FileItem) {
|
||||
self.files.insert(position, file);
|
||||
}
|
||||
|
||||
/// Remove file at index. Simple - no HashMap to maintain!
|
||||
fn remove_file(&mut self, index: usize) {
|
||||
if index < self.files.len() {
|
||||
self.files.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove files matching predicate.
|
||||
/// Returns number of files removed.
|
||||
fn retain_files<F>(&mut self, predicate: F) -> usize
|
||||
where
|
||||
F: FnMut(&FileItem) -> bool,
|
||||
{
|
||||
let initial_len = self.files.len();
|
||||
self.files.retain(predicate);
|
||||
initial_len - self.files.len()
|
||||
}
|
||||
|
||||
/// Insert a file in sorted order (by path).
|
||||
/// Returns true if inserted, false if file already exists.
|
||||
fn insert_file_sorted(&mut self, file: FileItem) -> bool {
|
||||
match self.find_file_index(&file.path) {
|
||||
Ok(_) => false, // File already exists
|
||||
Err(position) => {
|
||||
self.insert_file(position, file);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,39 +169,31 @@ impl FileItem {
|
||||
Err(_) => (0, 0),
|
||||
};
|
||||
|
||||
Self {
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
Self::new_raw(
|
||||
path,
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
relative_path,
|
||||
file_name_lower: name.to_lowercase(),
|
||||
file_name: name,
|
||||
name,
|
||||
size,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
}
|
||||
is_binary,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> {
|
||||
self.access_frecency_score = tracker.get_access_score(&self.path);
|
||||
pub fn update_frecency_scores(
|
||||
&mut self,
|
||||
tracker: &FrecencyTracker,
|
||||
mode: FFFMode,
|
||||
) -> Result<(), Error> {
|
||||
self.access_frecency_score = tracker.get_access_score(&self.path, mode);
|
||||
self.modification_frecency_score =
|
||||
tracker.get_modification_score(self.modified, self.git_status);
|
||||
tracker.get_modification_score(self.modified, self.git_status, mode);
|
||||
self.total_frecency_score = self.access_frecency_score + self.modification_frecency_score;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locks the tracker and updates frecensy score for one file. If need multiple files updates
|
||||
/// use `update_frecency_scores` instead.
|
||||
pub fn update_frecency_scores_global(&mut self) -> Result<(), Error> {
|
||||
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.update_frecency_scores(frecency)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilePicker {
|
||||
@@ -118,6 +202,9 @@ pub struct FilePicker {
|
||||
is_scanning: Arc<AtomicBool>,
|
||||
scanned_files_count: Arc<AtomicUsize>,
|
||||
background_watcher: Option<BackgroundWatcher>,
|
||||
warmup_mmap_cache: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
mode: FFFMode,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
@@ -139,40 +226,86 @@ impl FilePicker {
|
||||
&self.base_path
|
||||
}
|
||||
|
||||
pub fn warmup_mmap_cache(&self) -> bool {
|
||||
self.warmup_mmap_cache
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> FFFMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn git_root(&self) -> Option<&Path> {
|
||||
self.sync_data.git_workdir.as_deref()
|
||||
}
|
||||
|
||||
/// Get all indexed files sorted by path.
|
||||
/// Note: Files are stored sorted by PATH for efficient insert/remove.
|
||||
/// For frecency-sorted results, use search() which sorts matched results.
|
||||
pub fn get_files(&self) -> &[FileItem] {
|
||||
&self.sync_data.files
|
||||
self.sync_data.files()
|
||||
}
|
||||
|
||||
pub fn new(base_path: String) -> Result<Self, Error> {
|
||||
info!("Initializing FilePicker with base_path: {}", base_path);
|
||||
/// 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,
|
||||
mode: FFFMode,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
info!(
|
||||
"Initializing FilePicker with base_path: {}, warmup: {}, mode: {:?}",
|
||||
base_path, warmup_mmap_cache, mode
|
||||
);
|
||||
let path = PathBuf::from(&base_path);
|
||||
if !path.exists() {
|
||||
error!("Base path does not exist: {}", base_path);
|
||||
return Err(Error::InvalidPath(path));
|
||||
}
|
||||
|
||||
let scan_signal = Arc::new(AtomicBool::new(false));
|
||||
// Initialize scan_signal to `true` so that any `wait_for_scan` call
|
||||
// that races with the background thread sees "scanning in progress"
|
||||
// rather than a stale `false` (the thread hasn't started yet).
|
||||
let scan_signal = Arc::new(AtomicBool::new(true));
|
||||
let synced_files_count = Arc::new(AtomicUsize::new(0));
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let picker = Self {
|
||||
let picker = FilePicker {
|
||||
base_path: path.clone(),
|
||||
sync_data: FileSync::new(),
|
||||
is_scanning: Arc::clone(&scan_signal),
|
||||
scanned_files_count: Arc::clone(&synced_files_count),
|
||||
background_watcher: None,
|
||||
warmup_mmap_cache,
|
||||
cancelled: Arc::clone(&cancelled),
|
||||
mode,
|
||||
};
|
||||
|
||||
// Place the picker into the shared handle before spawning the
|
||||
// background thread so the thread can find it immediately.
|
||||
{
|
||||
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
*guard = Some(picker);
|
||||
}
|
||||
|
||||
spawn_scan_and_watcher(
|
||||
path.clone(),
|
||||
Arc::clone(&scan_signal),
|
||||
Arc::clone(&synced_files_count),
|
||||
warmup_mmap_cache,
|
||||
mode,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
cancelled,
|
||||
);
|
||||
|
||||
Ok(picker)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform fuzzy search on files with a pre-parsed query.
|
||||
@@ -194,7 +327,13 @@ impl FilePicker {
|
||||
parsed: Option<FFFQuery<'a>>,
|
||||
options: FuzzySearchOptions<'a>,
|
||||
) -> SearchResult<'a> {
|
||||
let max_threads = options.max_threads.max(1);
|
||||
let max_threads = if options.max_threads == 0 {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
} else {
|
||||
options.max_threads
|
||||
};
|
||||
debug!(
|
||||
?query,
|
||||
parsed_is_some = parsed.is_some(),
|
||||
@@ -266,38 +405,47 @@ impl FilePicker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_git_statuses(&mut self, status_cache: GitStatusCache) -> Result<(), Error> {
|
||||
/// Update git statuses for files, using the provided shared frecency tracker.
|
||||
pub fn update_git_statuses(
|
||||
&mut self,
|
||||
status_cache: GitStatusCache,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
debug!(
|
||||
statuses_count = status_cache.statuses_len(),
|
||||
"Updating git status",
|
||||
);
|
||||
|
||||
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
let mode = self.mode;
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
status_cache
|
||||
.into_iter()
|
||||
.try_for_each(|(path, status)| -> Result<(), Error> {
|
||||
if let Some(file) = self.get_mut_file_by_path(&path) {
|
||||
file.git_status = Some(status);
|
||||
|
||||
if let Some(frecency) = frecency.as_ref() {
|
||||
file.update_frecency_scores(frecency)?;
|
||||
if let Some(ref f) = *frecency {
|
||||
file.update_frecency_scores(f, mode)?;
|
||||
}
|
||||
} else {
|
||||
error!(?path, "Couldn't update the git status for path");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches all the git statuses first and updates the global FILE_PICKER
|
||||
/// with the new statuses with the smallest possible lock time.
|
||||
pub fn refresh_git_status_global() -> Result<usize, Error> {
|
||||
/// Refreshes git statuses using the provided shared picker and frecency handles.
|
||||
pub fn refresh_git_status(
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<usize, Error> {
|
||||
let git_status = {
|
||||
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
let guard = shared_picker.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
|
||||
debug!(
|
||||
@@ -305,29 +453,22 @@ impl FilePicker {
|
||||
picker.git_root()
|
||||
);
|
||||
|
||||
// we keep here readonly lock but allowing querying the index while it scan lasts
|
||||
GitStatusCache::read_git_status(
|
||||
picker.git_root(),
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
// when manually refreshing git status we want to include all unmodified file
|
||||
// to make sure that their status is correctly updated when user
|
||||
// commited/stashed/removed changes
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true),
|
||||
)
|
||||
};
|
||||
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
|
||||
|
||||
let statuses_count = if let Some(git_status) = git_status {
|
||||
let count = git_status.statuses_len();
|
||||
picker.update_git_statuses(git_status)?;
|
||||
|
||||
picker.update_git_statuses(git_status, shared_frecency)?;
|
||||
count
|
||||
} else {
|
||||
0
|
||||
@@ -342,9 +483,9 @@ impl FilePicker {
|
||||
frecency_tracker: &FrecencyTracker,
|
||||
) -> Result<(), Error> {
|
||||
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
|
||||
&& let Some(file) = self.sync_data.files.get_mut(index)
|
||||
&& let Some(file) = self.sync_data.get_file_mut(index)
|
||||
{
|
||||
file.update_frecency_scores(frecency_tracker)?;
|
||||
file.update_frecency_scores(frecency_tracker, self.mode)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -354,35 +495,38 @@ impl FilePicker {
|
||||
self.sync_data
|
||||
.find_file_index(path.as_ref())
|
||||
.ok()
|
||||
.and_then(|index| self.sync_data.files.get(index))
|
||||
.and_then(|index| self.sync_data.files().get(index))
|
||||
}
|
||||
|
||||
pub fn get_mut_file_by_path(&mut self, path: impl AsRef<Path>) -> Option<&mut FileItem> {
|
||||
self.sync_data
|
||||
.find_file_index(path.as_ref())
|
||||
.ok()
|
||||
.and_then(|index| self.sync_data.files.get_mut(index))
|
||||
.and_then(|index| self.sync_data.get_file_mut(index))
|
||||
}
|
||||
|
||||
/// Add a file to the picker's files in sorted order (used by background watcher)
|
||||
pub fn add_file_sorted(&mut self, file: FileItem) -> Option<&FileItem> {
|
||||
match self
|
||||
.sync_data
|
||||
.files
|
||||
.binary_search_by(|f| f.relative_path.cmp(&file.relative_path))
|
||||
{
|
||||
Ok(position) => {
|
||||
warn!(
|
||||
"Trying to insert a file that already exists: {}",
|
||||
file.relative_path
|
||||
);
|
||||
let path = file.path.clone();
|
||||
|
||||
self.sync_data.files.get(position)
|
||||
}
|
||||
Err(position) => {
|
||||
self.sync_data.files.insert(position, file);
|
||||
self.sync_data.files.get(position)
|
||||
}
|
||||
if self.sync_data.insert_file_sorted(file) {
|
||||
// File was inserted, look it up
|
||||
self.sync_data
|
||||
.find_file_index(&path)
|
||||
.ok()
|
||||
.and_then(|idx| self.sync_data.get_file_mut(idx))
|
||||
.map(|file_mut| &*file_mut) // Convert &mut to &
|
||||
} else {
|
||||
// File already exists
|
||||
warn!(
|
||||
"Trying to insert a file that already exists: {}",
|
||||
path.display()
|
||||
);
|
||||
self.sync_data
|
||||
.find_file_index(&path)
|
||||
.ok()
|
||||
.and_then(|idx| self.sync_data.get_file_mut(idx))
|
||||
.map(|file_mut| &*file_mut) // Convert &mut to &
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,8 +535,12 @@ impl FilePicker {
|
||||
let path = path.as_ref();
|
||||
match self.sync_data.find_file_index(path) {
|
||||
Ok(pos) => {
|
||||
// safe to read because we are in lock and binary search returned valid position
|
||||
let file = &mut self.sync_data.files[pos];
|
||||
debug!(
|
||||
"on_create_or_modify: file EXISTS at index {}, updating metadata",
|
||||
pos
|
||||
);
|
||||
// File exists - update its metadata (doesn't change indices, safe)
|
||||
let file = self.sync_data.get_file_mut(pos)?;
|
||||
|
||||
let modified = match std::fs::metadata(path) {
|
||||
Ok(metadata) => metadata
|
||||
@@ -409,24 +557,49 @@ impl FilePicker {
|
||||
let modified = modified.as_secs();
|
||||
if file.modified < modified {
|
||||
file.modified = modified;
|
||||
|
||||
// TODO figure out if we actually need to remap the memory or invalidate
|
||||
// mapping here because on linux and macos with the shared map opening it
|
||||
// should be automatically available everywhere automatically which saves
|
||||
// some time from doing extra remapping on every search
|
||||
file.invalidate_mmap();
|
||||
}
|
||||
}
|
||||
|
||||
Some(file)
|
||||
Some(&*file) // Convert &mut to &
|
||||
}
|
||||
Err(pos) => {
|
||||
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
|
||||
self.sync_data.files.insert(pos, file_item);
|
||||
debug!(
|
||||
"on_create_or_modify: file NEW, inserting at index {} (total files: {})",
|
||||
pos,
|
||||
self.sync_data.files().len()
|
||||
);
|
||||
|
||||
self.sync_data.files.get(pos)
|
||||
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
|
||||
let path_buf = file_item.path.clone();
|
||||
|
||||
self.sync_data.insert_file(pos, file_item);
|
||||
let result = self.sync_data.get_file(pos);
|
||||
|
||||
if result.is_none() {
|
||||
error!(
|
||||
"on_create_or_modify: FAILED to find file after insert! path={:?}",
|
||||
path_buf
|
||||
);
|
||||
} else {
|
||||
debug!("on_create_or_modify: successfully inserted and found file");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
|
||||
match self.sync_data.find_file_index(path.as_ref()) {
|
||||
let path = path.as_ref();
|
||||
match self.sync_data.find_file_index(path) {
|
||||
Ok(index) => {
|
||||
self.sync_data.files.remove(index);
|
||||
self.sync_data.remove_file(index);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
@@ -436,13 +609,14 @@ impl FilePicker {
|
||||
// TODO make this O(n)
|
||||
pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
|
||||
let dir_path = dir.as_ref();
|
||||
let initial_len = self.sync_data.files.len();
|
||||
|
||||
// Use the safe retain_files method which maintains both indices
|
||||
self.sync_data
|
||||
.files
|
||||
.retain(|file| !file.path.starts_with(dir_path));
|
||||
.retain_files(|file| !file.path.starts_with(dir_path))
|
||||
}
|
||||
|
||||
initial_len - self.sync_data.files.len()
|
||||
/// We use this to prevent any substantial background threads from acquiring the locks
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(&mut self) {
|
||||
@@ -451,7 +625,7 @@ impl FilePicker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_rescan(&mut self) -> Result<(), Error> {
|
||||
pub fn trigger_rescan(&mut self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
|
||||
if self.is_scanning.load(Ordering::Relaxed) {
|
||||
debug!("Scan already in progress, skipping trigger_rescan");
|
||||
return Ok(());
|
||||
@@ -460,7 +634,12 @@ impl FilePicker {
|
||||
self.is_scanning.store(true, Ordering::Relaxed);
|
||||
self.scanned_files_count.store(0, Ordering::Relaxed);
|
||||
|
||||
let scan_result = scan_filesystem(&self.base_path, &self.scanned_files_count);
|
||||
let scan_result = scan_filesystem(
|
||||
&self.base_path,
|
||||
&self.scanned_files_count,
|
||||
shared_frecency,
|
||||
self.mode,
|
||||
);
|
||||
match scan_result {
|
||||
Ok(sync) => {
|
||||
info!(
|
||||
@@ -468,7 +647,15 @@ impl FilePicker {
|
||||
sync.files.len()
|
||||
);
|
||||
|
||||
self.sync_data = sync
|
||||
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"),
|
||||
}
|
||||
@@ -480,6 +667,12 @@ impl FilePicker {
|
||||
pub fn is_scan_active(&self) -> bool {
|
||||
self.is_scanning.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Return a clone of the scanning flag so callers can poll it without
|
||||
/// holding a lock on the picker.
|
||||
pub fn scan_signal(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.is_scanning)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
@@ -489,28 +682,56 @@ pub struct ScanProgress {
|
||||
pub is_scanning: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_scan_and_watcher(
|
||||
base_path: PathBuf,
|
||||
scan_signal: Arc<AtomicBool>,
|
||||
synced_files_count: Arc<AtomicUsize>,
|
||||
warmup_mmap_cache: bool,
|
||||
mode: FFFMode,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
scan_signal.store(true, Ordering::Relaxed);
|
||||
// scan_signal is already `true` (set by the caller before spawning)
|
||||
// so waiters see "scanning" even before this thread is scheduled.
|
||||
info!("Starting initial file scan");
|
||||
|
||||
let mut git_workdir = None;
|
||||
match scan_filesystem(&base_path, &synced_files_count) {
|
||||
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency, mode) {
|
||||
Ok(sync) => {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Scan completed but picker was replaced, discarding results");
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Initial filesystem scan completed: found {} files",
|
||||
sync.files.len()
|
||||
);
|
||||
|
||||
git_workdir = sync.git_workdir.clone();
|
||||
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
|
||||
// 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
|
||||
{
|
||||
picker.sync_data = sync;
|
||||
warmup_mmaps(picker.sync_data.files());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -519,14 +740,39 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
|
||||
match BackgroundWatcher::new(base_path, git_workdir) {
|
||||
// Don't create a watcher if this picker instance was already replaced
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, skipping background watcher creation");
|
||||
return;
|
||||
}
|
||||
|
||||
match BackgroundWatcher::new(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
mode,
|
||||
) {
|
||||
Ok(watcher) => {
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
{
|
||||
picker.background_watcher = Some(watcher);
|
||||
// Final cancellation check: if the picker was replaced between
|
||||
// watcher creation and this write, drop the watcher instead of
|
||||
// storing it in the wrong picker.
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, dropping orphaned watcher");
|
||||
drop(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.background_watcher = Some(watcher);
|
||||
}
|
||||
});
|
||||
|
||||
if write_result.is_none() {
|
||||
error!("Failed to store background watcher in picker");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -538,9 +784,36 @@ fn spawn_scan_and_watcher(
|
||||
});
|
||||
}
|
||||
|
||||
/// Pre-populate mmap caches for all eligible files so the first grep search
|
||||
/// doesn't pay the mmap creation + page fault cost.
|
||||
///
|
||||
/// Each file is mmap'd and a single byte is read to trigger the page fault.
|
||||
/// This runs in parallel using rayon.
|
||||
#[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,
|
||||
mode: FFFMode,
|
||||
) -> Result<FileSync, Error> {
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
use std::thread;
|
||||
@@ -585,7 +858,7 @@ fn scan_filesystem(
|
||||
.build_parallel();
|
||||
|
||||
let walker_start = std::time::Instant::now();
|
||||
info!("SCAN: Starting file walker");
|
||||
debug!("SCAN: Starting file walker");
|
||||
|
||||
let files = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
walker.run(|| {
|
||||
@@ -627,7 +900,10 @@ fn scan_filesystem(
|
||||
Error::ThreadPanic
|
||||
})?;
|
||||
|
||||
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
|
||||
files
|
||||
.par_iter_mut()
|
||||
.try_for_each(|file| -> Result<(), Error> {
|
||||
@@ -636,7 +912,7 @@ fn scan_filesystem(
|
||||
}
|
||||
|
||||
if let Some(frecency) = frecency.as_ref() {
|
||||
file.update_frecency_scores(frecency)?;
|
||||
file.update_frecency_scores(frecency, mode)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -649,7 +925,6 @@ fn scan_filesystem(
|
||||
files.len()
|
||||
);
|
||||
|
||||
// Sort by OsStr instead of Path to avoid expensive component-by-component comparison
|
||||
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
|
||||
Ok(FileSync { files, git_workdir })
|
||||
})
|
||||
|
||||
+251
-13
@@ -1,11 +1,13 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::{error::Error, git::is_modified_status};
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::{SharedFrecency, error::Error, git::is_modified_status};
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{
|
||||
EnvFlags,
|
||||
types::{Bytes, SerdeBincode},
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{collections::VecDeque, path::Path};
|
||||
|
||||
@@ -13,6 +15,10 @@ const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
|
||||
const SECONDS_PER_DAY: f64 = 86400.0;
|
||||
const MAX_HISTORY_DAYS: f64 = 30.0; // Only consider accesses within 30 days
|
||||
|
||||
// AI mode: faster decay since AI sessions are shorter and more intense
|
||||
const AI_DECAY_CONSTANT: f64 = 0.231; // ln(2)/3 for 3-day half-life
|
||||
const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FrecencyTracker {
|
||||
env: Env,
|
||||
@@ -27,6 +33,15 @@ const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(1, 60 * 60 * 24 * 7), // 1 week
|
||||
];
|
||||
|
||||
// AI mode: compressed thresholds since AI edits happen in rapid bursts
|
||||
const AI_MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(16, 30), // 30 seconds
|
||||
(8, 60 * 5), // 5 minutes
|
||||
(4, 60 * 15), // 15 minutes
|
||||
(2, 60 * 60), // 1 hour
|
||||
(1, 60 * 60 * 4), // 4 hours
|
||||
];
|
||||
|
||||
impl DbHealthChecker for FrecencyTracker {
|
||||
fn get_env(&self) -> &heed::Env {
|
||||
&self.env
|
||||
@@ -45,6 +60,7 @@ impl FrecencyTracker {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.map_size(24 * 1024 * 1024); // 24 MiB
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
}
|
||||
@@ -65,6 +81,200 @@ impl FrecencyTracker {
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawns a background thread to purge stale frecency entries and compact the database.
|
||||
///
|
||||
/// Phase 1 (read lock): purge stale entries — deletes expired entries and prunes old timestamps.
|
||||
/// Phase 2 (write lock): compact the database by re-writing entries into a fresh LMDB env.
|
||||
/// We can't use LMDB's copy_to_path with NO_LOCK envs (MDB_INCOMPATIBLE),
|
||||
/// so instead we: read all entries → drop env → delete files → reopen → write back.
|
||||
pub fn spawn_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
|
||||
std::thread::Builder::new()
|
||||
.name("fff-frecency-gc".into())
|
||||
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
|
||||
fn run_frecency_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
|
||||
let start = std::time::Instant::now();
|
||||
let data_path = PathBuf::from(&db_path).join("data.mdb");
|
||||
|
||||
// Phase 1: Purge stale entries.
|
||||
// The RwLock protects the Option<FrecencyTracker> (not the DB itself),
|
||||
// so a read lock is sufficient — LMDB handles its own write serialization.
|
||||
let (deleted, pruned) = {
|
||||
let guard = match shared.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to acquire read lock: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(ref tracker) = *guard else {
|
||||
return;
|
||||
};
|
||||
match tracker.purge_stale_entries() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
tracing::debug!("Purge failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if deleted > 0 || pruned > 0 {
|
||||
tracing::info!(deleted, pruned, elapsed = ?start.elapsed(), "Frecency GC purged entries");
|
||||
}
|
||||
|
||||
// Compact if we purged entries OR the file has significant freelist bloat
|
||||
let file_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
|
||||
if deleted == 0 && pruned == 0 && file_size <= 512 * 1024 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Manual compaction under a single write lock
|
||||
let mut guard = match shared.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to acquire write lock: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Read all entries from current env
|
||||
let entries: Vec<(Vec<u8>, VecDeque<u64>)> = match guard.as_ref() {
|
||||
Some(tracker) => {
|
||||
let rtxn = match tracker.env.read_txn() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::debug!("Compaction read_txn failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let iter = match tracker.db.iter(&rtxn) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::debug!("Compaction iter failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut read_errors = 0u32;
|
||||
for result in iter {
|
||||
match result {
|
||||
Ok((key, value)) => entries.push((key.to_vec(), value)),
|
||||
Err(_) => read_errors += 1,
|
||||
}
|
||||
}
|
||||
if read_errors > 0 {
|
||||
tracing::warn!(
|
||||
read_errors,
|
||||
"Skipped corrupted entries during compaction read"
|
||||
);
|
||||
}
|
||||
entries
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Drop old tracker, delete files, create fresh env, write back
|
||||
*guard = None;
|
||||
|
||||
let lock_path = PathBuf::from(&db_path).join("lock.mdb");
|
||||
let _ = fs::remove_file(&data_path);
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
|
||||
let tracker = match FrecencyTracker::new(&db_path, use_unsafe_no_lock) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("Compaction reopen failed, frecency disabled: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let write_result = (|| -> std::result::Result<(), heed::Error> {
|
||||
let mut wtxn = tracker.env.write_txn()?;
|
||||
for (key, value) in &entries {
|
||||
tracker.db.put(&mut wtxn, key.as_slice(), value)?;
|
||||
}
|
||||
wtxn.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match write_result {
|
||||
Ok(()) => {
|
||||
let new_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
|
||||
*guard = Some(tracker);
|
||||
tracing::debug!(
|
||||
entries = entries.len(),
|
||||
old_size = file_size,
|
||||
new_size,
|
||||
elapsed = ?start.elapsed(),
|
||||
"Frecency DB compacted"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Compaction write failed, frecency data may be incomplete: {e}");
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes entries where all timestamps are older than MAX_HISTORY_DAYS,
|
||||
/// and prunes stale timestamps from entries that still have recent ones.
|
||||
/// Returns (deleted_count, pruned_count).
|
||||
fn purge_stale_entries(&self) -> Result<(usize, usize), Error> {
|
||||
let now = self.get_now();
|
||||
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
|
||||
|
||||
// Collect entries to delete or update
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let mut to_delete: Vec<Vec<u8>> = Vec::new();
|
||||
let mut to_update: Vec<(Vec<u8>, VecDeque<u64>)> = Vec::new();
|
||||
|
||||
let iter = self.db.iter(&rtxn).map_err(Error::DbRead)?;
|
||||
for result in iter {
|
||||
let (key, accesses) = result.map_err(Error::DbRead)?;
|
||||
|
||||
// Timestamps are chronologically ordered (oldest at front).
|
||||
// Find the first timestamp that is still within the retention window.
|
||||
let fresh_start = accesses.iter().position(|&ts| ts >= cutoff_time);
|
||||
match fresh_start {
|
||||
None => {
|
||||
// All timestamps are stale — delete the entire entry
|
||||
to_delete.push(key.to_vec());
|
||||
}
|
||||
Some(0) => {
|
||||
// All timestamps are fresh — nothing to do
|
||||
}
|
||||
Some(start) => {
|
||||
// Some timestamps are stale — keep only the fresh ones
|
||||
let pruned: VecDeque<u64> = accesses.iter().skip(start).copied().collect();
|
||||
to_update.push((key.to_vec(), pruned));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(rtxn);
|
||||
|
||||
if to_delete.is_empty() && to_update.is_empty() {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
|
||||
// Apply all changes in a single write transaction
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
for key in &to_delete {
|
||||
self.db.delete(&mut wtxn, key).map_err(Error::DbWrite)?;
|
||||
}
|
||||
for (key, accesses) in &to_update {
|
||||
self.db
|
||||
.put(&mut wtxn, key, accesses)
|
||||
.map_err(Error::DbWrite)?;
|
||||
}
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
Ok((to_delete.len(), to_update.len()))
|
||||
}
|
||||
|
||||
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
@@ -87,6 +297,14 @@ impl FrecencyTracker {
|
||||
Ok(*blake3::hash(key.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
/// Returns seconds since the most recent tracked access, or `None` if the
|
||||
/// file has never been tracked.
|
||||
pub fn seconds_since_last_access(&self, path: &Path) -> Result<Option<u64>, Error> {
|
||||
let accesses = self.get_accesses(path)?;
|
||||
let last = accesses.and_then(|a| a.back().copied());
|
||||
Ok(last.map(|ts| self.get_now().saturating_sub(ts)))
|
||||
}
|
||||
|
||||
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
@@ -115,7 +333,7 @@ impl FrecencyTracker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_access_score(&self, file_path: &Path) -> i64 {
|
||||
pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
|
||||
let accesses = self
|
||||
.get_accesses(file_path)
|
||||
.ok()
|
||||
@@ -126,10 +344,21 @@ impl FrecencyTracker {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let decay_constant = if mode.is_ai() {
|
||||
AI_DECAY_CONSTANT
|
||||
} else {
|
||||
DECAY_CONSTANT
|
||||
};
|
||||
let max_history_days = if mode.is_ai() {
|
||||
AI_MAX_HISTORY_DAYS
|
||||
} else {
|
||||
MAX_HISTORY_DAYS
|
||||
};
|
||||
|
||||
let now = self.get_now();
|
||||
let mut total_frecency = 0.0;
|
||||
|
||||
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
|
||||
let cutoff_time = now.saturating_sub((max_history_days * SECONDS_PER_DAY) as u64);
|
||||
|
||||
for &access_time in accesses.iter().rev() {
|
||||
if access_time < cutoff_time {
|
||||
@@ -137,7 +366,7 @@ impl FrecencyTracker {
|
||||
}
|
||||
|
||||
let days_ago = (now.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
|
||||
let decay_factor = (-DECAY_CONSTANT * days_ago).exp();
|
||||
let decay_factor = (-decay_constant * days_ago).exp();
|
||||
total_frecency += decay_factor;
|
||||
}
|
||||
|
||||
@@ -155,24 +384,31 @@ impl FrecencyTracker {
|
||||
&self,
|
||||
modified_time: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
mode: FFFMode,
|
||||
) -> i64 {
|
||||
let is_modified_git_status = git_status.is_some_and(is_modified_status);
|
||||
if !is_modified_git_status {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let thresholds = if mode.is_ai() {
|
||||
&AI_MODIFICATION_THRESHOLDS
|
||||
} else {
|
||||
&MODIFICATION_THRESHOLDS
|
||||
};
|
||||
|
||||
let now = self.get_now();
|
||||
let duration_since = now.saturating_sub(modified_time);
|
||||
|
||||
for i in 0..MODIFICATION_THRESHOLDS.len() {
|
||||
let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i];
|
||||
for i in 0..thresholds.len() {
|
||||
let (current_points, current_threshold) = thresholds[i];
|
||||
|
||||
if duration_since <= current_threshold {
|
||||
if i == 0 || duration_since == current_threshold {
|
||||
return current_points;
|
||||
}
|
||||
|
||||
let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1];
|
||||
let (prev_points, prev_threshold) = thresholds[i - 1];
|
||||
|
||||
let time_range = current_threshold - prev_threshold;
|
||||
let time_offset = duration_since - prev_threshold;
|
||||
@@ -192,6 +428,7 @@ impl FrecencyTracker {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::file_picker::FFFMode;
|
||||
|
||||
fn calculate_test_frecency_score(access_timestamps: &[u64], current_time: u64) -> i64 {
|
||||
let mut total_frecency = 0.0;
|
||||
@@ -269,34 +506,35 @@ mod tests {
|
||||
|
||||
// At 5 minutes: should interpolate between 16 and 8 points
|
||||
let five_minutes_ago = current_time - (5 * 60);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, git_status);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, git_status, FFFMode::Neovim);
|
||||
|
||||
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
|
||||
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
|
||||
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
|
||||
|
||||
let two_minutes_ago = current_time - (2 * 60);
|
||||
let score = tracker.get_modification_score(two_minutes_ago, git_status);
|
||||
let score = tracker.get_modification_score(two_minutes_ago, git_status, FFFMode::Neovim);
|
||||
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
|
||||
|
||||
let fifteen_minutes_ago = current_time - (15 * 60);
|
||||
let score = tracker.get_modification_score(fifteen_minutes_ago, git_status);
|
||||
let score =
|
||||
tracker.get_modification_score(fifteen_minutes_ago, git_status, FFFMode::Neovim);
|
||||
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
|
||||
|
||||
// At 12 hours: should interpolate between 4 and 2 points
|
||||
let twelve_hours_ago = current_time - (12 * 60 * 60);
|
||||
let score = tracker.get_modification_score(twelve_hours_ago, git_status);
|
||||
let score = tracker.get_modification_score(twelve_hours_ago, git_status, FFFMode::Neovim);
|
||||
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
|
||||
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
|
||||
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
|
||||
|
||||
// at 18 hours for more significant interpolation
|
||||
let eighteen_hours_ago = current_time - (18 * 60 * 60);
|
||||
let score = tracker.get_modification_score(eighteen_hours_ago, git_status);
|
||||
let score = tracker.get_modification_score(eighteen_hours_ago, git_status, FFFMode::Neovim);
|
||||
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
|
||||
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
|
||||
|
||||
let score = tracker.get_modification_score(five_minutes_ago, None);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, None, FFFMode::Neovim);
|
||||
assert_eq!(score, 0, "No git status should return 0");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
+16
-12
@@ -125,31 +125,35 @@ pub fn is_modified_status(status: Status) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
|
||||
match status {
|
||||
None => "clear",
|
||||
None => Some("clean"),
|
||||
Some(status) => {
|
||||
if status.contains(Status::WT_NEW) {
|
||||
"untracked"
|
||||
Some("untracked")
|
||||
} else if status.contains(Status::WT_MODIFIED) {
|
||||
"modified"
|
||||
Some("modified")
|
||||
} else if status.contains(Status::WT_DELETED) {
|
||||
"deleted"
|
||||
Some("deleted")
|
||||
} else if status.contains(Status::WT_RENAMED) {
|
||||
"renamed"
|
||||
Some("renamed")
|
||||
} else if status.contains(Status::INDEX_NEW) {
|
||||
"staged_new"
|
||||
Some("staged_new")
|
||||
} else if status.contains(Status::INDEX_MODIFIED) {
|
||||
"staged_modified"
|
||||
Some("staged_modified")
|
||||
} else if status.contains(Status::INDEX_DELETED) {
|
||||
"staged_deleted"
|
||||
Some("staged_deleted")
|
||||
} else if status.contains(Status::IGNORED) {
|
||||
"ignored"
|
||||
Some("ignored")
|
||||
} else if status.contains(Status::CURRENT) || status.is_empty() {
|
||||
"clean"
|
||||
Some("clean")
|
||||
} else {
|
||||
"unknown"
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
format_git_status_opt(status).unwrap_or("unknown")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
-12
@@ -1,7 +1,13 @@
|
||||
//! fff-core - High-performance file finder library
|
||||
//!
|
||||
//! This crate provides the core file indexing and fuzzy search functionality.
|
||||
//! It maintains global state for the file picker, frecency tracker, and query tracker.
|
||||
//!
|
||||
//! # State management
|
||||
//!
|
||||
//! All state is instance-based. Callers create their own `SharedPicker` /
|
||||
//! `SharedFrecency` / `SharedQueryTracker` and pass them into
|
||||
//! `FilePicker::new_with_shared_state`. Multiple independent instances can
|
||||
//! coexist in the same process.
|
||||
|
||||
mod background_watcher;
|
||||
pub mod constraints;
|
||||
@@ -10,6 +16,8 @@ mod error;
|
||||
pub mod file_picker;
|
||||
pub mod frecency;
|
||||
pub mod git;
|
||||
pub mod grep;
|
||||
pub mod log;
|
||||
pub mod path_utils;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
@@ -18,22 +26,21 @@ pub mod types;
|
||||
|
||||
use file_picker::FilePicker;
|
||||
use frecency::FrecencyTracker;
|
||||
use once_cell::sync::Lazy;
|
||||
use query_tracker::QueryTracker;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
// Global state - same pattern as fff-nvim
|
||||
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
|
||||
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
|
||||
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use file_picker::{FuzzySearchOptions, ScanProgress};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
|
||||
// Re-export query parser types (including Location which moved there)
|
||||
pub use fff_query_parser::{
|
||||
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
|
||||
};
|
||||
pub use file_picker::{FFFMode, FuzzySearchOptions, ScanProgress};
|
||||
pub use grep::{
|
||||
GrepMatch, GrepMode, GrepResult, GrepSearchOptions, has_regex_metacharacters,
|
||||
is_definition_line, is_import_line, multi_grep_search,
|
||||
};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Shared logging utilities for FFF crates.
|
||||
//!
|
||||
//! Provides file-based tracing initialization and a panic hook that writes
|
||||
//! to both stderr and a fallback log file.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install panic hook that writes to both stderr and a fallback file.
|
||||
/// This is called separately from init_tracing to ensure panics are always logged.
|
||||
pub fn install_panic_hook() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("=================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
}
|
||||
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Parse a log level string into a `tracing::Level`.
|
||||
///
|
||||
/// Accepts "trace", "debug", "info", "warn", "error" (case-insensitive).
|
||||
/// Returns `tracing::Level::INFO` for unrecognised values.
|
||||
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
|
||||
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
Some("debug") => tracing::Level::DEBUG,
|
||||
Some("info") => tracing::Level::INFO,
|
||||
Some("warn") => tracing::Level::WARN,
|
||||
Some("error") => tracing::Level::ERROR,
|
||||
_ => tracing::Level::INFO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize tracing with a single log file.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist, truncates the log file,
|
||||
/// and sets up a non-blocking file appender with structured formatting.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true) // creates a new file on every setup
|
||||
.open(log_path)?;
|
||||
|
||||
let level = parse_log_level(log_level);
|
||||
|
||||
TRACING_INITIALIZED.get_or_init(|| {
|
||||
let (non_blocking_appender, guard) = non_blocking(file_appender);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF tracing initialized with log file: {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
//! 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 {
|
||||
|
||||
@@ -31,8 +31,10 @@ pub struct QueryTracker {
|
||||
env: Env,
|
||||
// Database for (project_path, query) -> QueryMatchEntry mappings
|
||||
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
|
||||
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings (grep)
|
||||
grep_query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
}
|
||||
|
||||
impl DbHealthChecker for QueryTracker {
|
||||
@@ -45,10 +47,15 @@ impl DbHealthChecker for QueryTracker {
|
||||
|
||||
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),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -58,6 +65,7 @@ impl QueryTracker {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.map_size(10 * 1024 * 1024); // 100 MiB
|
||||
opts.max_dbs(16); // Allow up to 16 databases per environment
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
@@ -77,6 +85,9 @@ impl QueryTracker {
|
||||
let query_history_db = env
|
||||
.create_database(&mut wtxn, Some("query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
let grep_query_history_db = env
|
||||
.create_database(&mut wtxn, Some("grep_query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
@@ -84,6 +95,7 @@ impl QueryTracker {
|
||||
env,
|
||||
query_file_db,
|
||||
query_history_db,
|
||||
grep_query_history_db,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,6 +127,57 @@ impl QueryTracker {
|
||||
Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
/// Append a query to a history database within an existing write transaction.
|
||||
fn append_to_history(
|
||||
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
wtxn: &mut heed::RwTxn,
|
||||
project_key: &[u8; 32],
|
||||
query: &str,
|
||||
now: u64,
|
||||
) -> Result<(), Error> {
|
||||
let mut history = db
|
||||
.get(wtxn, project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
history.push_back(HistoryEntry {
|
||||
query: query.to_string(),
|
||||
timestamp: now,
|
||||
});
|
||||
while history.len() > MAX_HISTORY_ENTRIES {
|
||||
history.pop_front();
|
||||
}
|
||||
|
||||
db.put(wtxn, project_key, &history)
|
||||
.map_err(Error::DbWrite)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a query from a history database at a specific offset.
|
||||
/// offset=0 returns most recent, offset=1 returns 2nd most recent, etc.
|
||||
fn read_history_at_offset(
|
||||
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
env: &Env,
|
||||
project_key: &[u8; 32],
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let mut history = db
|
||||
.get(&rtxn, project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
// history is FIFO, last element is most recent
|
||||
if history.len() > offset {
|
||||
let index = history.len() - 1 - offset;
|
||||
let record = history.remove(index);
|
||||
Ok(record.map(|r| r.query))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track_query_completion(
|
||||
&mut self,
|
||||
query: &str,
|
||||
@@ -166,24 +229,7 @@ impl QueryTracker {
|
||||
|
||||
// Update query history database
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let mut history = self
|
||||
.query_history_db
|
||||
.get(&wtxn, &project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let history_entry = HistoryEntry {
|
||||
query: query.to_string(),
|
||||
timestamp: now,
|
||||
};
|
||||
history.push_back(history_entry);
|
||||
while history.len() > MAX_HISTORY_ENTRIES {
|
||||
history.pop_front();
|
||||
}
|
||||
|
||||
self.query_history_db
|
||||
.put(&mut wtxn, &project_key, &history)
|
||||
.map_err(Error::DbWrite)?;
|
||||
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
@@ -237,32 +283,47 @@ impl QueryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get query from history at a specific offset
|
||||
/// Get query from file picker history at a specific offset.
|
||||
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
|
||||
/// Returns None if offset exceeds history length
|
||||
pub fn get_historical_query(
|
||||
&self,
|
||||
project_path: &Path,
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
Self::read_history_at_offset(&self.query_history_db, &self.env, &project_key, offset)
|
||||
}
|
||||
|
||||
let mut history = self
|
||||
.query_history_db
|
||||
.get(&rtxn, &project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
/// Track a grep query in the grep-specific history.
|
||||
/// Only records query history (no file association tracking needed for grep).
|
||||
pub fn track_grep_query(&mut self, query: &str, project_path: &Path) -> Result<(), Error> {
|
||||
let now = self.get_now();
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
// history is FIFO, last element is most recent
|
||||
if history.len() > offset {
|
||||
let index = history.len() - 1 - offset;
|
||||
let record = history.remove(index);
|
||||
Self::append_to_history(
|
||||
&self.grep_query_history_db,
|
||||
&mut wtxn,
|
||||
&project_key,
|
||||
query,
|
||||
now,
|
||||
)?;
|
||||
|
||||
Ok(record.map(|r| r.query))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
tracing::debug!(?query, "Tracked grep query");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get grep query from history at a specific offset.
|
||||
/// offset=0 returns most recent grep query, offset=1 returns 2nd most recent, etc.
|
||||
pub fn get_historical_grep_query(
|
||||
&self,
|
||||
project_path: &Path,
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
Self::read_history_at_offset(&self.grep_query_history_db, &self.env, &project_key, offset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use rayon::prelude::*;
|
||||
use std::path::MAIN_SEPARATOR;
|
||||
|
||||
// like cow but better
|
||||
enum FileItems<'a> {
|
||||
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.
|
||||
@@ -172,7 +172,6 @@ pub fn match_and_score_files<'a>(
|
||||
let query_contains_path_separator = fuzzy_parts.iter().any(|p| p.contains(MAIN_SEPARATOR));
|
||||
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(context.max_typos),
|
||||
sort: false,
|
||||
scoring: Scoring {
|
||||
@@ -230,6 +229,14 @@ pub fn match_and_score_files<'a>(
|
||||
|
||||
let mut base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
|
||||
|
||||
// Give modified/dirty files a 15% boost to make them appear higher in results
|
||||
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
|
||||
base_score * 15 / 100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let distance_penalty =
|
||||
calculate_distance_penalty(context.current_file, &file.relative_path);
|
||||
|
||||
@@ -296,6 +303,7 @@ pub fn match_and_score_files<'a>(
|
||||
|
||||
let total = base_score
|
||||
.saturating_add(frecency_boost)
|
||||
.saturating_add(git_status_boost)
|
||||
.saturating_add(distance_penalty)
|
||||
.saturating_add(filename_bonus)
|
||||
.saturating_add(current_file_penalty)
|
||||
@@ -312,6 +320,7 @@ pub fn match_and_score_files<'a>(
|
||||
0
|
||||
},
|
||||
frecency_boost,
|
||||
git_status_boost,
|
||||
distance_penalty,
|
||||
combo_match_boost,
|
||||
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
|
||||
@@ -355,7 +364,7 @@ fn is_special_entry_point_file(filename: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Score files by frecency when we have a filtered list (prefiltered by constraints)
|
||||
fn score_filtered_by_frecency<'a>(
|
||||
pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
files: &FileItems<'a>,
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
@@ -363,9 +372,18 @@ fn score_filtered_by_frecency<'a>(
|
||||
let total_frecency_score = file.access_frecency_score as i32
|
||||
+ (file.modification_frecency_score as i32).saturating_mul(4);
|
||||
|
||||
// Give modified/dirty files a boost even in frecency-only mode
|
||||
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
|
||||
total_frecency_score * 15 / 100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let current_file_penalty =
|
||||
calculate_current_file_penalty(file, total_frecency_score, context);
|
||||
let total = total_frecency_score.saturating_add(current_file_penalty);
|
||||
let total = total_frecency_score
|
||||
.saturating_add(git_status_boost)
|
||||
.saturating_add(current_file_penalty);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
@@ -376,6 +394,7 @@ fn score_filtered_by_frecency<'a>(
|
||||
combo_match_boost: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
git_status_boost,
|
||||
exact_match: false,
|
||||
match_type: "frecency",
|
||||
};
|
||||
@@ -485,19 +504,16 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
|
||||
let file = FileItem {
|
||||
path: PathBuf::from(path),
|
||||
relative_path: path.to_string(),
|
||||
relative_path_lower: path.to_lowercase(),
|
||||
file_name: path.split('/').last().unwrap_or(path).to_string(),
|
||||
file_name_lower: path.split('/').last().unwrap_or(path).to_lowercase(),
|
||||
size: 0,
|
||||
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
|
||||
let file = FileItem::new_raw(
|
||||
PathBuf::from(path),
|
||||
path.to_string(),
|
||||
file_name,
|
||||
0,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status: None,
|
||||
};
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let score_obj = Score {
|
||||
total: score,
|
||||
base_score: score,
|
||||
@@ -506,6 +522,7 @@ mod tests {
|
||||
special_filename_bonus: 0,
|
||||
current_file_penalty: 0,
|
||||
frecency_boost: 0,
|
||||
git_status_boost: 0,
|
||||
exact_match: false,
|
||||
match_type: "test",
|
||||
combo_match_boost: 0,
|
||||
@@ -571,7 +588,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_partial_sort_with_same_scores() {
|
||||
// Test tiebreaker with modified time
|
||||
let test_data = vec![
|
||||
let test_data = [
|
||||
create_test_file("file1.rs", 100, 5000), // Same score, older
|
||||
create_test_file("file2.rs", 100, 8000), // Same score, newer
|
||||
create_test_file("file3.rs", 100, 3000), // Same score, oldest
|
||||
@@ -620,7 +637,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_no_partial_sort_for_small_results() {
|
||||
// When results.len() <= threshold, should use regular sort
|
||||
let test_data = vec![
|
||||
let test_data = [
|
||||
create_test_file("file1.rs", 100, 1000),
|
||||
create_test_file("file2.rs", 200, 2000),
|
||||
create_test_file("file3.rs", 50, 3000),
|
||||
@@ -669,7 +686,6 @@ mod multi_part_tests {
|
||||
|
||||
// Test with max_typos = 2 (safe for short needles)
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
@@ -701,7 +717,6 @@ mod multi_part_tests {
|
||||
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs".to_lowercase();
|
||||
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
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};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 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,
|
||||
@@ -17,6 +29,101 @@ pub struct FileItem {
|
||||
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 {
|
||||
@@ -48,6 +155,7 @@ pub struct Score {
|
||||
pub filename_bonus: i32,
|
||||
pub special_filename_bonus: i32,
|
||||
pub frecency_boost: i32,
|
||||
pub git_status_boost: i32,
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "fff-mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "fff-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["zlob"]
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[dependencies]
|
||||
fff-core = { path = "../fff-core", default-features = false }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
mimalloc = { workspace = true }
|
||||
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
|
||||
schemars = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
fn main() {
|
||||
// Embed the git commit hash at build time for update checking.
|
||||
let hash = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!("cargo:rustc-env=FFF_GIT_HASH={}", hash);
|
||||
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../../.git/refs/");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Cursor store for grep pagination.
|
||||
//!
|
||||
//! Maintains an in-memory map of opaque cursor IDs to file offsets.
|
||||
//! Cursors are evicted LRU-style when the store exceeds capacity.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
const MAX_CURSORS: usize = 20;
|
||||
|
||||
/// Stores cursor state for paginated grep results.
|
||||
pub struct CursorStore {
|
||||
counter: u64,
|
||||
/// Map from cursor ID string → file offset for next page.
|
||||
cursors: HashMap<String, usize>,
|
||||
/// Insertion order for LRU eviction.
|
||||
insertion_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl CursorStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
counter: 0,
|
||||
cursors: HashMap::new(),
|
||||
insertion_order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a cursor and return its opaque ID string.
|
||||
pub fn store(&mut self, file_offset: usize) -> String {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
let id = self.counter.to_string();
|
||||
|
||||
self.cursors.insert(id.clone(), file_offset);
|
||||
self.insertion_order.push_back(id.clone());
|
||||
|
||||
// Evict oldest cursors
|
||||
while self.cursors.len() > MAX_CURSORS {
|
||||
if let Some(oldest) = self.insertion_order.pop_front() {
|
||||
self.cursors.remove(&oldest);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Retrieve the file offset for a cursor ID.
|
||||
pub fn get(&self, id: &str) -> Option<usize> {
|
||||
self.cursors.get(id).copied()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
//!
|
||||
//! Drop-in replacement for AI code assistant file search tools (Glob/Grep).
|
||||
//! Provides frecency-ranked, fuzzy-matched, git-aware file finding and
|
||||
//! code search via the Model Context Protocol (MCP).
|
||||
//!
|
||||
//! Uses `fff-core` directly (zero FFI overhead) for all search operations.
|
||||
|
||||
mod cursor;
|
||||
mod output;
|
||||
mod server;
|
||||
mod update_check;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use clap::Parser;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::{FFFMode, SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use mimalloc::MiMalloc;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use server::FffServer;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
pub const MCP_INSTRUCTIONS: &str = concat!(
|
||||
"FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n",
|
||||
"\n",
|
||||
"## Which Tool Should I Use?\n",
|
||||
"\n",
|
||||
"- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n",
|
||||
"- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n",
|
||||
"- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n",
|
||||
"\n",
|
||||
"## Core Rules\n",
|
||||
"\n",
|
||||
"### 1. Search BARE IDENTIFIERS only\n",
|
||||
"Grep matches single lines. Search for ONE identifier per query:\n",
|
||||
" + 'InProgressQuote' -> finds definition + all usages\n",
|
||||
" + 'ActorAuth' -> finds enum, struct, all call sites\n",
|
||||
" x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n",
|
||||
" x 'ctx.data::<ActorAuth>' -> code syntax, too specific, 0 results\n",
|
||||
" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n",
|
||||
" x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n",
|
||||
"\n",
|
||||
"### 2. NEVER use regex unless you truly need alternation\n",
|
||||
"Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n",
|
||||
"If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n",
|
||||
"\n",
|
||||
"### 3. Stop searching after 2 greps -- READ the code\n",
|
||||
"After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n",
|
||||
"Do NOT keep grepping with variations. More greps != better understanding.\n",
|
||||
"\n",
|
||||
"### 4. Use multi_grep for multiple identifiers\n",
|
||||
"When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n",
|
||||
" + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n",
|
||||
" x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n",
|
||||
"\n",
|
||||
"## Workflow\n",
|
||||
"\n",
|
||||
"**Have a specific name?** -> grep the bare identifier.\n",
|
||||
"**Need multiple name variants?** -> multi_grep with all variants in one call.\n",
|
||||
"**Exploring a topic / finding files?** -> find_files.\n",
|
||||
"**Got results?** -> Read the top file. Don't grep again.\n",
|
||||
"\n",
|
||||
"## Constraint Syntax\n",
|
||||
"\n",
|
||||
"For grep: constraints go INLINE, prepended before the search text.\n",
|
||||
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
|
||||
"\n",
|
||||
"Constraints MUST match one of these formats:\n",
|
||||
" Extension: '*.rs', '*.{ts,tsx}' (starts with *.)\n",
|
||||
" Directory: 'src/', 'quotes/' (ends with /)\n",
|
||||
" Exclude: '!test/', '!*.spec.ts' (starts with !)\n",
|
||||
"\n",
|
||||
"! Bare words are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
|
||||
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
|
||||
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
|
||||
"\n",
|
||||
"Prefer broad constraints:\n",
|
||||
" + '*.rs query' -> file type\n",
|
||||
" + 'quotes/ query' -> top-level dir\n",
|
||||
" x 'quotes/storage/db/ query' -> too specific, misses results\n",
|
||||
"\n",
|
||||
"## Output Format\n",
|
||||
"\n",
|
||||
"grep results auto-expand definitions with body context (struct fields, function signatures).\n",
|
||||
"This often provides enough information WITHOUT a follow-up Read call.\n",
|
||||
"Lines marked with | are definition body context. [def] marks definition files.\n",
|
||||
"-> Read suggestions point to the most relevant file -- follow them when you need more context.\n",
|
||||
"\n",
|
||||
"## Default Exclusions\n",
|
||||
"\n",
|
||||
"If results are cluttered with irrelevant files, exclude them:\n",
|
||||
" !tests/ - exclude tests directory\n",
|
||||
" !*.spec.ts - exclude test files\n",
|
||||
" !generated/ - exclude generated code",
|
||||
);
|
||||
|
||||
/// FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fff-mcp", version = env!("CARGO_PKG_VERSION"))]
|
||||
struct Args {
|
||||
/// Base directory to index. Defaults to the current working directory.
|
||||
#[arg(value_name = "PATH")]
|
||||
base_path: Option<String>,
|
||||
|
||||
/// Path to the frecency database.
|
||||
#[arg(long = "frecency-db")]
|
||||
frecency_db_path: Option<String>,
|
||||
|
||||
/// Path to the query history database.
|
||||
#[arg(long = "history-db")]
|
||||
#[allow(dead_code)]
|
||||
history_db_path: Option<String>,
|
||||
|
||||
/// Path to the log file.
|
||||
#[arg(long = "log-file")]
|
||||
log_file: Option<String>,
|
||||
|
||||
/// Log level (e.g. trace, debug, info, warn, error).
|
||||
#[arg(long = "log-level")]
|
||||
log_level: Option<String>,
|
||||
|
||||
/// Disable automatic update checks on startup.
|
||||
#[arg(long = "no-update-check")]
|
||||
no_update_check: bool,
|
||||
}
|
||||
|
||||
/// Resolve default paths for frecency db, history db, and log file.
|
||||
/// Shares Neovim's standard data locations when they exist so the MCP
|
||||
/// server and fff.nvim plugin use the same databases.
|
||||
fn resolve_defaults(args: &mut Args) {
|
||||
let home = dirs_home();
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
|
||||
let nvim_cache_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.cache/nvim", home)
|
||||
};
|
||||
let nvim_data_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.local/share/nvim", home)
|
||||
};
|
||||
|
||||
let use_nvim_paths = std::path::Path::new(&nvim_cache_dir).exists()
|
||||
|| std::path::Path::new(&nvim_data_dir).exists();
|
||||
|
||||
if args.frecency_db_path.is_none() {
|
||||
args.frecency_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_nvim", nvim_cache_dir)
|
||||
} else {
|
||||
format!("{}/.fff/frecency.mdb", home)
|
||||
});
|
||||
}
|
||||
if args.history_db_path.is_none() {
|
||||
args.history_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_queries", nvim_data_dir)
|
||||
} else {
|
||||
format!("{}/.fff/history.mdb", home)
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure parent directories exist for database paths
|
||||
for path in [&args.frecency_db_path, &args.history_db_path]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
if args.log_file.is_none() {
|
||||
args.log_file = Some(if is_windows {
|
||||
format!("{}\\AppData\\Local\\fff_mcp.log", home)
|
||||
} else {
|
||||
format!("{}/.cache/fff_mcp.log", home)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_home() -> String {
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| "/tmp".to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = Args::parse();
|
||||
resolve_defaults(&mut args);
|
||||
|
||||
let log_file = args.log_file.as_deref().unwrap_or("");
|
||||
if let Err(e) = fff_core::log::init_tracing(log_file, args.log_level.as_deref()) {
|
||||
eprintln!("Warning: Failed to init tracing: {}", e);
|
||||
}
|
||||
|
||||
let base_path = args.base_path.unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
if !Repository::discover(&base_path).is_ok() {
|
||||
tracing::error!("MCP server must be run within a Git repository");
|
||||
return Err(format!("Not a Git repository: {}", base_path).into());
|
||||
}
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
match FrecencyTracker::new(&frecency_db_path, false) {
|
||||
Ok(tracker) => {
|
||||
if let Ok(mut guard) = shared_frecency.write() {
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to init frecency db: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize file picker (spawns background scan + watcher)
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
true, // warmup_mmap_cache
|
||||
FFFMode::Ai,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
.map_err(|e| format!("Failed to init file picker: {}", e))?;
|
||||
|
||||
if !args.no_update_check {
|
||||
update_check::spawn_update_check();
|
||||
}
|
||||
|
||||
// Create and start the MCP server
|
||||
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
|
||||
|
||||
// Wait for initial scan in background — don't block server startup
|
||||
let picker_clone_for_scan = Arc::clone(&shared_picker);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
let is_scanning = picker_clone_for_scan
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|p| p.is_scan_active()))
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_scanning {
|
||||
tracing::info!("Initial scan completed in {:?}", start.elapsed());
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
|
||||
let service = server
|
||||
.serve(stdio())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start MCP server: {}", e))?;
|
||||
|
||||
let picker_for_shutdown = shared_picker.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
if let Ok(mut guard) = picker_for_shutdown.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
std::process::exit(0);
|
||||
});
|
||||
|
||||
service.waiting().await?;
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
//! Output formatting for MCP grep/search results.
|
||||
//!
|
||||
//! Port of `packages/fff-mcp/src/output.ts` — token-efficient formatting
|
||||
//! with definition auto-expansion, frecency/git annotations, and Read suggestions.
|
||||
|
||||
use fff_core::GrepMatch;
|
||||
use fff_core::git::format_git_status_opt;
|
||||
use fff_core::grep::is_import_line;
|
||||
use fff_core::types::FileItem;
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
|
||||
/// Frecency score → single-token word. `None` for low-scoring files.
|
||||
fn frecency_word(score: i64) -> Option<&'static str> {
|
||||
if score >= 100 {
|
||||
Some("hot")
|
||||
} else if score >= 50 {
|
||||
Some("warm")
|
||||
} else if score >= 10 {
|
||||
Some("frequent")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build " - hot git:modified" style suffix. Empty when nothing to report.
|
||||
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i64) -> String {
|
||||
match (
|
||||
frecency_word(frecency_score),
|
||||
format_git_status_opt(git_status),
|
||||
) {
|
||||
(Some(f), Some(g)) => format!(" - {f} git:{g}"),
|
||||
(Some(f), None) => format!(" - {f}"),
|
||||
(None, Some(g)) => format!(" git:{g}"),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutputMode {
|
||||
Content,
|
||||
FilesWithMatches,
|
||||
Count,
|
||||
Usage,
|
||||
}
|
||||
|
||||
impl OutputMode {
|
||||
pub fn new(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
Some("files_with_matches") => Self::FilesWithMatches,
|
||||
Some("count") => Self::Count,
|
||||
Some("usage") => Self::Usage,
|
||||
_ => Self::Content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LARGE_FILE_BYTES: u64 = 20_000;
|
||||
|
||||
/// Tag for large files — nudges model to use offset/limit when reading.
|
||||
fn size_tag(bytes: u64) -> String {
|
||||
if bytes < LARGE_FILE_BYTES {
|
||||
String::new()
|
||||
} else {
|
||||
let kb = (bytes + 512) / 1024; // round
|
||||
format!(" ({}KB - use offset to read relevant section)", kb)
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_PREVIEW: usize = 120;
|
||||
const MAX_LINE_LEN: usize = 180;
|
||||
/// Max context lines to show when auto-expanding the first definition
|
||||
const MAX_DEF_EXPAND_FIRST: usize = 8;
|
||||
/// Max context lines for subsequent definitions
|
||||
const MAX_DEF_EXPAND: usize = 5;
|
||||
/// Max context lines for non-definition first match in small result sets
|
||||
const MAX_FIRST_MATCH_EXPAND: usize = 8;
|
||||
|
||||
fn trauncate_line_for_ai(
|
||||
line: &str,
|
||||
match_ranges: Option<&[(u32, u32)]>,
|
||||
max_len: usize,
|
||||
) -> String {
|
||||
// Strip leading/trailing whitespace to save tokens — the LLM has file:line for location.
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let strip_offset = line.len() - line.trim_start().len();
|
||||
|
||||
if trimmed.len() <= max_len {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
// Adjust match ranges for the stripped leading whitespace
|
||||
let adjusted: Vec<(u32, u32)>;
|
||||
let ranges = match match_ranges {
|
||||
Some(r) if strip_offset > 0 => {
|
||||
let off = strip_offset as u32;
|
||||
adjusted = r
|
||||
.iter()
|
||||
.map(|&(s, e)| (s.saturating_sub(off), e.saturating_sub(off)))
|
||||
.collect();
|
||||
Some(adjusted.as_slice())
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
|
||||
// Use first match range to center the window
|
||||
if let Some(ranges) = ranges
|
||||
&& let Some(&(match_start, match_end)) = ranges.first()
|
||||
{
|
||||
let match_start = match_start as usize;
|
||||
let match_end = match_end as usize;
|
||||
let match_len = match_end.saturating_sub(match_start);
|
||||
|
||||
let budget = max_len.saturating_sub(match_len);
|
||||
let before = budget / 3;
|
||||
let after = budget - before;
|
||||
|
||||
let win_start = match_start.saturating_sub(before);
|
||||
let win_end = (match_end + after).min(trimmed.len());
|
||||
|
||||
// Clamp to char boundaries
|
||||
let win_start = floor_char_boundary(trimmed, win_start);
|
||||
let win_end = ceil_char_boundary(trimmed, win_end);
|
||||
|
||||
let mut result = trimmed[win_start..win_end].to_string();
|
||||
if win_start > 0 {
|
||||
result.insert(0, '…');
|
||||
}
|
||||
if win_end < trimmed.len() {
|
||||
result.push('…');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// No match ranges — truncate from start
|
||||
let end = ceil_char_boundary(trimmed, max_len);
|
||||
format!("{}…", &trimmed[..end])
|
||||
}
|
||||
|
||||
/// Floor to a valid char boundary
|
||||
fn floor_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut i = index;
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Ceil to a valid char boundary
|
||||
fn ceil_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut i = index;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Collected file metadata for the first match per file.
|
||||
struct FileMeta<'a> {
|
||||
file: &'a FileItem,
|
||||
line_number: u64,
|
||||
line_content: String,
|
||||
is_definition: bool,
|
||||
match_ranges: Vec<(u32, u32)>,
|
||||
context_after: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for [`format_grep_results`].
|
||||
///
|
||||
/// Groups the read-only inputs so callers don't juggle 10 positional args.
|
||||
pub struct GrepFormatter<'a> {
|
||||
pub matches: &'a [GrepMatch],
|
||||
pub files: &'a [&'a FileItem],
|
||||
pub total_matched: usize,
|
||||
pub next_file_offset: usize,
|
||||
pub regex_fallback_error: Option<&'a str>,
|
||||
pub output_mode: OutputMode,
|
||||
pub max_results: usize,
|
||||
pub show_context: bool,
|
||||
pub auto_expand_defs: bool,
|
||||
}
|
||||
|
||||
impl GrepFormatter<'_> {
|
||||
pub fn format(&self, cursor_store: &mut CursorStore) -> String {
|
||||
let GrepFormatter {
|
||||
matches,
|
||||
files,
|
||||
total_matched,
|
||||
next_file_offset,
|
||||
regex_fallback_error,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context,
|
||||
auto_expand_defs,
|
||||
} = *self;
|
||||
|
||||
let items = if matches.len() > max_results {
|
||||
&matches[..max_results]
|
||||
} else {
|
||||
matches
|
||||
};
|
||||
|
||||
if output_mode == OutputMode::FilesWithMatches {
|
||||
return format_files_with_matches(
|
||||
items,
|
||||
files,
|
||||
next_file_offset,
|
||||
auto_expand_defs,
|
||||
cursor_store,
|
||||
);
|
||||
}
|
||||
|
||||
if output_mode == OutputMode::Count {
|
||||
return format_count(items, files, next_file_offset, cursor_store);
|
||||
}
|
||||
|
||||
// output_mode == usage
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let unique_files = {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for m in items {
|
||||
seen.insert(m.file_index);
|
||||
}
|
||||
seen.len()
|
||||
};
|
||||
|
||||
let max_output_chars: usize = if output_mode == OutputMode::Usage || unique_files <= 3 {
|
||||
5000
|
||||
} else if unique_files <= 8 {
|
||||
3500
|
||||
} else {
|
||||
2500
|
||||
};
|
||||
|
||||
if let Some(err) = regex_fallback_error {
|
||||
lines.push(format!("! regex failed: {}, using literal match", err));
|
||||
}
|
||||
|
||||
// File overview: collect first match per file
|
||||
let file_preview = collect_file_preview(items, files);
|
||||
let mut content_def_file = "";
|
||||
let mut content_first_file = "";
|
||||
for fm in &file_preview {
|
||||
if content_first_file.is_empty() {
|
||||
content_first_file = &fm.file.relative_path;
|
||||
}
|
||||
if content_def_file.is_empty() && fm.is_definition {
|
||||
content_def_file = &fm.file.relative_path;
|
||||
}
|
||||
}
|
||||
|
||||
let content_suggest = if !content_def_file.is_empty() {
|
||||
content_def_file
|
||||
} else {
|
||||
content_first_file
|
||||
};
|
||||
if !content_suggest.is_empty() {
|
||||
let file_count = file_preview.len();
|
||||
if file_count == 1 {
|
||||
lines.push(format!("→ Read {} (only match)", content_suggest));
|
||||
} else if !content_def_file.is_empty() {
|
||||
lines.push(format!("→ Read {} [def]", content_suggest));
|
||||
} else if file_count <= 3 {
|
||||
lines.push(format!("→ Read {} (best match)", content_suggest));
|
||||
}
|
||||
}
|
||||
|
||||
if total_matched > items.len() {
|
||||
lines.push(format!("{}/{} matches shown", items.len(), total_matched));
|
||||
}
|
||||
|
||||
// Track which files already had a definition expanded
|
||||
let mut def_expanded_files = std::collections::HashSet::new();
|
||||
|
||||
// Detailed content (subject to budget)
|
||||
let mut char_count = 0usize;
|
||||
let mut shown_count = 0usize;
|
||||
let mut current_file = "";
|
||||
|
||||
// Reorder: definitions first, then usages, then imports (when auto-expanding)
|
||||
let sorted_items: Vec<usize> = if auto_expand_defs {
|
||||
let mut indices: Vec<usize> = (0..items.len()).collect();
|
||||
indices.sort_unstable_by_key(|&i| {
|
||||
if items[i].is_definition {
|
||||
0
|
||||
} else if is_import_line(&items[i].line_content) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
|
||||
indices
|
||||
} else {
|
||||
(0..items.len()).collect()
|
||||
};
|
||||
|
||||
for &idx in &sorted_items {
|
||||
let m = &items[idx];
|
||||
let file = files[m.file_index];
|
||||
let mut match_lines: Vec<String> = Vec::new();
|
||||
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
match_lines.push(current_file.to_string());
|
||||
}
|
||||
|
||||
// Skip import-only lines when we already have definitions
|
||||
if auto_expand_defs && is_import_line(&m.line_content) && !def_expanded_files.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Context before (only when explicitly requested)
|
||||
if show_context && !m.context_before.is_empty() {
|
||||
let start_line = m.line_number.saturating_sub(m.context_before.len() as u64);
|
||||
for (i, ctx) in m.context_before.iter().enumerate() {
|
||||
match_lines.push(format!(
|
||||
" {}-{}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Match line
|
||||
match_lines.push(format!(
|
||||
" {}: {}",
|
||||
m.line_number,
|
||||
trauncate_line_for_ai(
|
||||
&m.line_content,
|
||||
Some(m.match_byte_offsets.as_ref()),
|
||||
MAX_LINE_LEN
|
||||
)
|
||||
));
|
||||
|
||||
// Context after (only when explicitly requested via context parameter)
|
||||
if show_context && !m.context_after.is_empty() {
|
||||
let start_line = m.line_number + 1;
|
||||
for (i, ctx) in m.context_after.iter().enumerate() {
|
||||
match_lines.push(format!(
|
||||
" {}-{}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
|
||||
));
|
||||
}
|
||||
match_lines.push("--".to_string());
|
||||
}
|
||||
|
||||
// Auto-expand definitions with body context
|
||||
if auto_expand_defs
|
||||
&& !show_context
|
||||
&& m.is_definition
|
||||
&& !m.context_after.is_empty()
|
||||
&& !def_expanded_files.contains(file.relative_path.as_str())
|
||||
{
|
||||
let expand_limit = if def_expanded_files.is_empty() {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_files.insert(file.relative_path.as_str());
|
||||
let start_line = m.line_number + 1;
|
||||
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
|
||||
if ctx.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
match_lines.push(format!(
|
||||
" {}| {}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let chunk = match_lines.join("\n");
|
||||
if char_count + chunk.len() > max_output_chars && shown_count > 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
char_count += chunk.len();
|
||||
lines.push(chunk);
|
||||
shown_count += 1;
|
||||
}
|
||||
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
lines.push(format!("\ncursor: {}", cursor_id));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_files_with_matches(
|
||||
items: &[GrepMatch],
|
||||
files: &[&FileItem],
|
||||
next_file_offset: usize,
|
||||
auto_expand_defs: bool,
|
||||
cursor_store: &mut CursorStore,
|
||||
) -> String {
|
||||
let file_map = collect_file_preview(items, files);
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let file_count = file_map.len();
|
||||
|
||||
// Find best Read target
|
||||
let mut first_def_file = "";
|
||||
let mut first_file = "";
|
||||
for fm in &file_map {
|
||||
if first_file.is_empty() {
|
||||
first_file = &fm.file.relative_path;
|
||||
}
|
||||
if first_def_file.is_empty() && fm.is_definition {
|
||||
first_def_file = &fm.file.relative_path;
|
||||
}
|
||||
}
|
||||
let suggest_path = if !first_def_file.is_empty() {
|
||||
first_def_file
|
||||
} else {
|
||||
first_file
|
||||
};
|
||||
|
||||
if !suggest_path.is_empty() {
|
||||
if file_count == 1 {
|
||||
lines.push(format!(
|
||||
"→ Read {} (only match — no need to search further)",
|
||||
suggest_path
|
||||
));
|
||||
} else if !first_def_file.is_empty() && file_count <= 5 {
|
||||
lines.push(format!("→ Read {} (definition found)", suggest_path));
|
||||
} else if !first_def_file.is_empty() {
|
||||
lines.push(format!("→ Read {} (definition)", suggest_path));
|
||||
} else if file_count <= 3 {
|
||||
lines.push(format!("→ Read {} (best match)", suggest_path));
|
||||
} else {
|
||||
lines.push(format!("→ Read {}", suggest_path));
|
||||
}
|
||||
}
|
||||
|
||||
let is_small_set = file_count <= 5;
|
||||
let mut def_expanded_count = 0usize;
|
||||
|
||||
for (file_idx, fm) in file_map.iter().enumerate() {
|
||||
let is_def = fm.is_definition;
|
||||
let def_tag = if is_def { " [def]" } else { "" };
|
||||
lines.push(format!(
|
||||
"{}{}{}",
|
||||
fm.file.relative_path,
|
||||
def_tag,
|
||||
size_tag(fm.file.size)
|
||||
));
|
||||
|
||||
// Show preview
|
||||
if !fm.line_content.is_empty() && (is_def || file_idx == 0 || is_small_set) {
|
||||
let ranges_ref: Option<&[(u32, u32)]> = if fm.match_ranges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&fm.match_ranges)
|
||||
};
|
||||
lines.push(format!(
|
||||
" {}: {}",
|
||||
fm.line_number,
|
||||
trauncate_line_for_ai(&fm.line_content, ranges_ref, MAX_PREVIEW)
|
||||
));
|
||||
|
||||
// Auto-expand body context
|
||||
if auto_expand_defs && !fm.context_after.is_empty() {
|
||||
let expand_limit = if is_def {
|
||||
let limit = if def_expanded_count == 0 {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_count += 1;
|
||||
limit
|
||||
} else if is_small_set && file_idx == 0 {
|
||||
MAX_FIRST_MATCH_EXPAND
|
||||
} else if is_small_set {
|
||||
MAX_DEF_EXPAND
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if expand_limit > 0 {
|
||||
let start_line = fm.line_number + 1;
|
||||
for (i, ctx) in fm.context_after.iter().take(expand_limit).enumerate() {
|
||||
if ctx.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
lines.push(format!(
|
||||
" {}| {}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_PREVIEW)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
lines.push(format!("\ncursor: {}", cursor_id));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_count(
|
||||
items: &[GrepMatch],
|
||||
files: &[&FileItem],
|
||||
next_file_offset: usize,
|
||||
cursor_store: &mut CursorStore,
|
||||
) -> String {
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
let mut order: Vec<&str> = Vec::new();
|
||||
for m in items {
|
||||
let path = files[m.file_index].relative_path.as_str();
|
||||
let count = counts.entry(path).or_insert_with(|| {
|
||||
order.push(path);
|
||||
0
|
||||
});
|
||||
*count += 1;
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for path in &order {
|
||||
lines.push(format!("{}: {}", path, counts[*path]));
|
||||
}
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
lines.push(format!("\ncursor: {}", cursor_id));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<FileMeta<'a>> {
|
||||
let mut file_preview: Vec<FileMeta<'a>> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for m in items {
|
||||
let file = files[m.file_index];
|
||||
if seen.insert(&file.relative_path) {
|
||||
file_preview.push(FileMeta {
|
||||
file,
|
||||
line_number: m.line_number,
|
||||
line_content: m.line_content.clone(),
|
||||
is_definition: m.is_definition,
|
||||
match_ranges: m.match_byte_offsets.iter().copied().collect(),
|
||||
context_after: m.context_after.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
file_preview
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trunc_strips_whitespace() {
|
||||
assert_eq!(trauncate_line_for_ai(" foo()", None, 180), "foo()");
|
||||
assert_eq!(trauncate_line_for_ai(" bar ", None, 180), "bar");
|
||||
assert_eq!(trauncate_line_for_ai(" ", None, 180), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trunc_adjusts_match_ranges_after_strip() {
|
||||
// " hello" — match on "hello" at bytes 4..9
|
||||
let line = " hello";
|
||||
let ranges = [(4, 9)];
|
||||
let result = trauncate_line_for_ai(line, Some(&ranges), 180);
|
||||
// After stripping 4 leading spaces, the trimmed line is "hello"
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trunc_long_line_centered() {
|
||||
let line = format!("{}match_here{}", " ".repeat(8), "x".repeat(200));
|
||||
let ranges = [(8u32, 18u32)];
|
||||
let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
|
||||
assert!(result.contains("match_here"));
|
||||
assert!(result.len() <= 55); // budget + ellipsis chars
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
//! FFF MCP server — tool definitions and handlers.
|
||||
//!
|
||||
//! Uses the `rmcp` crate's `#[tool_router]` / `#[tool_handler]` macros
|
||||
//! for declarative tool registration. Each tool method directly calls
|
||||
//! `fff-core` APIs (no C FFI overhead).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{Constraint, FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff_query_parser::AiGrepConfig;
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::*;
|
||||
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
|
||||
/// Strip common delimiters for fuzzy fallback queries.
|
||||
fn strip_delimiters(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if !matches!(c, ':' | '-' | '_') {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute grep search options from output mode and context settings.
|
||||
fn make_grep_options(
|
||||
output_mode: OutputMode,
|
||||
mode: GrepMode,
|
||||
file_offset: usize,
|
||||
context: Option<usize>,
|
||||
) -> (GrepSearchOptions, bool) {
|
||||
let is_usage = output_mode == OutputMode::Usage;
|
||||
let matches_per_file = match output_mode {
|
||||
OutputMode::FilesWithMatches => 1,
|
||||
_ if is_usage => 8,
|
||||
_ => 10,
|
||||
};
|
||||
let ctx_lines = if is_usage {
|
||||
context.unwrap_or(1)
|
||||
} else {
|
||||
context.unwrap_or(0)
|
||||
};
|
||||
let auto_expand = !is_usage && ctx_lines == 0;
|
||||
let after_ctx = if auto_expand { 8 } else { ctx_lines };
|
||||
|
||||
(
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: matches_per_file,
|
||||
smart_case: true,
|
||||
file_offset,
|
||||
page_limit: 50,
|
||||
mode,
|
||||
time_budget_ms: 0,
|
||||
before_context: ctx_lines,
|
||||
after_context: after_ctx,
|
||||
classify_definitions: true,
|
||||
},
|
||||
auto_expand,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct FindFilesParams {
|
||||
/// Fuzzy search query. Supports path prefixes and glob constraints.
|
||||
pub query: String,
|
||||
/// Max results (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GrepParams {
|
||||
/// Search text or regex query with optional constraint prefixes.
|
||||
/// Matches within single lines only — use ONE specific term, not multiple words.
|
||||
pub query: String,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_patterns<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct PatternsVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for PatternsVisitor {
|
||||
type Value = Vec<String>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string, an array of strings, or a stringified JSON array")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
// Try to parse as JSON array first
|
||||
if v.starts_with('[')
|
||||
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(v)
|
||||
{
|
||||
return Ok(parsed);
|
||||
}
|
||||
Ok(vec![v.to_string()])
|
||||
}
|
||||
|
||||
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
|
||||
if v.starts_with('[')
|
||||
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(&v)
|
||||
{
|
||||
return Ok(parsed);
|
||||
}
|
||||
Ok(vec![v])
|
||||
}
|
||||
|
||||
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
|
||||
let mut values = Vec::new();
|
||||
while let Some(value) = seq.next_element::<String>()? {
|
||||
values.push(value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(PatternsVisitor)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct MultiGrepParams {
|
||||
/// Patterns to match (OR logic). Include all naming conventions: snake_case, PascalCase, camelCase.
|
||||
#[serde(deserialize_with = "deserialize_patterns")]
|
||||
pub patterns: Vec<String>,
|
||||
/// File constraints (e.g. '*.{ts,tsx} !test/'). ALWAYS provide when possible.
|
||||
pub constraints: Option<String>,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
/// Context lines before/after each match.
|
||||
pub context: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FffServer {
|
||||
picker: SharedPicker,
|
||||
#[allow(dead_code)]
|
||||
frecency: SharedFrecency,
|
||||
cursor_store: Arc<Mutex<CursorStore>>,
|
||||
update_notice_sent: Arc<AtomicBool>,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
|
||||
Self {
|
||||
picker,
|
||||
frecency,
|
||||
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
|
||||
update_notice_sent: Arc::new(AtomicBool::new(false)),
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the initial file scan to complete.
|
||||
#[allow(dead_code)]
|
||||
pub fn wait_for_scan(&self) {
|
||||
loop {
|
||||
let guard = self.picker.read().ok();
|
||||
let is_scanning = guard
|
||||
.as_ref()
|
||||
.and_then(|g| g.as_ref())
|
||||
.map(|p| p.is_scan_active())
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_scanning {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock the cursor store, returning an MCP error on poisoned mutex.
|
||||
fn lock_cursors(&self) -> Result<std::sync::MutexGuard<'_, CursorStore>, ErrorData> {
|
||||
self.cursor_store.lock().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire cursor store lock: {e}"), None)
|
||||
})
|
||||
}
|
||||
|
||||
/// If an update notice is available and hasn't been sent yet, append it
|
||||
/// to the tool result. Called once per server lifetime (first tool call).
|
||||
fn maybe_append_update_notice(&self, result: &mut CallToolResult) {
|
||||
if self.update_notice_sent.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let notice = crate::update_check::get_update_notice();
|
||||
if notice.is_empty() {
|
||||
// Reset so the next call can try again (check may still be in flight)
|
||||
self.update_notice_sent.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
result.content.push(Content::text(notice));
|
||||
}
|
||||
|
||||
/// Perform grep with auto-retry logic.
|
||||
///
|
||||
/// Acquires the picker read-lock once and holds it for the entire
|
||||
/// operation, so `GrepResult` references are used directly — no cloning.
|
||||
/// Always uses AI query parsing since this is an MCP server for AI agents.
|
||||
fn perform_grep(
|
||||
&self,
|
||||
query: &str,
|
||||
mode: GrepMode,
|
||||
max_results: usize,
|
||||
cursor_id: Option<&str>,
|
||||
output_mode: OutputMode,
|
||||
context: Option<usize>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let file_offset = cursor_id
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let (options, auto_expand) = make_grep_options(output_mode, mode, file_offset, context);
|
||||
let ctx_lines = options.before_context;
|
||||
|
||||
// Acquire picker lock once for the entire operation.
|
||||
let guard = self.picker.read().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
|
||||
})?;
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let parsed = parser.parse(query);
|
||||
let result = grep::grep_search(files, query, parsed.as_ref(), &options);
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
|
||||
let parts: Vec<&str> = query.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let first_word = parts[0];
|
||||
let is_valid_constraint = first_word.starts_with('!')
|
||||
|| first_word.starts_with('*')
|
||||
|| first_word.ends_with('/');
|
||||
|
||||
if !is_valid_constraint {
|
||||
let rest_query = parts[1..].join(" ");
|
||||
let rest_parsed = parser.parse(&rest_query);
|
||||
|
||||
let rest_text: Cow<str> = rest_parsed
|
||||
.as_ref()
|
||||
.map(|p| Cow::Owned(p.grep_text()))
|
||||
.unwrap_or(Cow::Borrowed(&rest_query));
|
||||
let retry_mode = if has_regex_metacharacters(&rest_text) {
|
||||
GrepMode::Regex
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
|
||||
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
|
||||
let retry_result =
|
||||
grep::grep_search(files, &rest_query, rest_parsed.as_ref(), &retry_options);
|
||||
|
||||
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &retry_result.matches,
|
||||
files: &retry_result.files,
|
||||
total_matched: retry_result.matches.len(),
|
||||
next_file_offset: retry_result.next_file_offset,
|
||||
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 matches for '{}'. Auto-broadened to '{}':\n{}",
|
||||
query, rest_query, text
|
||||
))]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy fallback for typo tolerance
|
||||
let fuzzy_query = strip_delimiters(&query.to_lowercase());
|
||||
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
|
||||
let fuzzy_parsed = parser.parse(&fuzzy_query);
|
||||
let fuzzy_result =
|
||||
grep::grep_search(files, &fuzzy_query, fuzzy_parsed.as_ref(), &fuzzy_options);
|
||||
|
||||
if !fuzzy_result.matches.is_empty() {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!(
|
||||
"0 exact matches. {} approximate:",
|
||||
fuzzy_result.matches.len()
|
||||
));
|
||||
let mut current_file = "";
|
||||
for m in fuzzy_result.matches.iter().take(3) {
|
||||
let file = fuzzy_result.files[m.file_index];
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
lines.push(current_file.to_string());
|
||||
}
|
||||
lines.push(format!(" {}: {}", m.line_number, m.line_content));
|
||||
}
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
lines.join("\n"),
|
||||
)]));
|
||||
}
|
||||
|
||||
let hint = match &parsed {
|
||||
Some(q)
|
||||
if q.constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::FilePath(_))) =>
|
||||
{
|
||||
let path = q
|
||||
.constraints
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
Constraint::FilePath(p) => Some(*p),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ext = path.rsplit('.').next().unwrap_or("");
|
||||
format!(
|
||||
" Constraint '{path}' looks like a file path — use Read to search in a specific file, or '*.{ext}' for extension filter."
|
||||
)
|
||||
}
|
||||
Some(q) if !q.constraints.is_empty() && !q.grep_text().is_empty() => {
|
||||
" Try to omit constraint".to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 matches {}",
|
||||
hint
|
||||
))]));
|
||||
}
|
||||
|
||||
if result.matches.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &result.matches,
|
||||
files: &result.files,
|
||||
total_matched: result.matches.len(),
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: result.regex_fallback_error.as_deref(),
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(text)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl FffServer {
|
||||
/// Fuzzy file search by name. Searches FILE NAMES, not file contents.
|
||||
/// Use it when you need to find a file, not a definition.
|
||||
/// Use grep instead for searching code content (definitions, usage patterns).
|
||||
/// Supports fuzzy matching, path prefixes ('shc/'), and glob constraints.
|
||||
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
|
||||
#[tool(
|
||||
name = "find_files",
|
||||
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
|
||||
)]
|
||||
fn find_files(
|
||||
&self,
|
||||
Parameters(params): Parameters<FindFilesParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let query = ¶ms.query;
|
||||
|
||||
let page_offset = params
|
||||
.cursor
|
||||
.as_deref()
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let guard = self.picker.read().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
|
||||
})?;
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
let base_path = picker.base_path();
|
||||
let make_opts = |offset: usize| FuzzySearchOptions {
|
||||
max_threads: 0,
|
||||
current_file: None,
|
||||
project_path: Some(base_path),
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset,
|
||||
limit: max_results,
|
||||
},
|
||||
};
|
||||
|
||||
let parser = QueryParser::default();
|
||||
let fff_query = parser.parse(query);
|
||||
let result = FilePicker::fuzzy_search(files, query, fff_query, make_opts(page_offset));
|
||||
let total_files = result.total_files;
|
||||
|
||||
// Auto-retry with fewer terms if 3+ words return 0 results
|
||||
let words: Vec<&str> = query.split_whitespace().collect();
|
||||
let shorter = words.get(..2).map(|w| w.join(" "));
|
||||
|
||||
let (items, scores, total_matched) =
|
||||
if result.items.is_empty() && words.len() >= 3 && page_offset == 0 {
|
||||
if let Some(shorter) = &shorter {
|
||||
let shorter_query = parser.parse(shorter);
|
||||
let retry =
|
||||
FilePicker::fuzzy_search(files, shorter, shorter_query, make_opts(0));
|
||||
|
||||
(retry.items, retry.scores, retry.total_matched)
|
||||
} else {
|
||||
(result.items, result.scores, result.total_matched)
|
||||
}
|
||||
} else {
|
||||
(result.items, result.scores, result.total_matched)
|
||||
};
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 results ({} indexed)",
|
||||
total_files
|
||||
))]));
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let top_item = items[0];
|
||||
let is_exact_match = scores[0].exact_match;
|
||||
|
||||
if page_offset == 0 {
|
||||
if is_exact_match {
|
||||
lines.push(format!("→ Read {} (exact match!)", top_item.relative_path));
|
||||
} else if scores.len() < 2 || scores[0].total > scores[1].total.saturating_mul(2) {
|
||||
lines.push(format!(
|
||||
"→ Read {} (best match — Read this file directly)",
|
||||
top_item.relative_path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let next_offset = page_offset + items.len();
|
||||
let has_more = next_offset < total_matched;
|
||||
|
||||
if has_more {
|
||||
lines.push(format!("{}/{} matches", items.len(), total_matched));
|
||||
}
|
||||
|
||||
for item in &items {
|
||||
lines.push(format!(
|
||||
"{}{}",
|
||||
item.relative_path,
|
||||
file_suffix(item.git_status, item.total_frecency_score)
|
||||
));
|
||||
}
|
||||
|
||||
if has_more {
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let cursor_id = cs.store(next_offset);
|
||||
lines.push(format!("cursor: {}", cursor_id));
|
||||
}
|
||||
|
||||
let mut result = CallToolResult::success(vec![Content::text(lines.join("\n"))]);
|
||||
self.maybe_append_update_notice(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Search file contents for text patterns. This is the DEFAULT search tool.
|
||||
/// Prefer plain text over regex. Filter files with constraints.
|
||||
#[tool(
|
||||
name = "grep",
|
||||
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules."
|
||||
)]
|
||||
fn grep(
|
||||
&self,
|
||||
Parameters(params): Parameters<GrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let parsed = QueryParser::new(AiGrepConfig).parse(¶ms.query);
|
||||
let grep_text: Cow<str> = parsed
|
||||
.as_ref()
|
||||
.map(|p| Cow::Owned(p.grep_text()))
|
||||
.unwrap_or(Cow::Borrowed(¶ms.query));
|
||||
|
||||
let mode = if has_regex_metacharacters(&grep_text) {
|
||||
GrepMode::Regex
|
||||
} else {
|
||||
GrepMode::PlainText
|
||||
};
|
||||
|
||||
let mut result = self.perform_grep(
|
||||
¶ms.query,
|
||||
mode,
|
||||
max_results,
|
||||
params.cursor.as_deref(),
|
||||
output_mode,
|
||||
None,
|
||||
)?;
|
||||
self.maybe_append_update_notice(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Search file contents for lines matching ANY of multiple patterns (OR logic).
|
||||
/// Patterns are literal text — NEVER escape special characters.
|
||||
#[tool(
|
||||
name = "multi_grep",
|
||||
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax."
|
||||
)]
|
||||
fn multi_grep(
|
||||
&self,
|
||||
Parameters(params): Parameters<MultiGrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let mut result = self.multi_grep_inner(params)?;
|
||||
self.maybe_append_update_notice(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let file_offset = params
|
||||
.cursor
|
||||
.as_deref()
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let (options, auto_expand) = make_grep_options(
|
||||
output_mode,
|
||||
GrepMode::PlainText,
|
||||
file_offset,
|
||||
params.context,
|
||||
);
|
||||
|
||||
let ctx_lines = options.before_context;
|
||||
let constraint_query = params.constraints.as_deref().unwrap_or("");
|
||||
let guard = self.picker.read().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
|
||||
})?;
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
|
||||
let parsed_constraints = if !constraint_query.is_empty() {
|
||||
parser.parse(constraint_query)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let constraints = parsed_constraints
|
||||
.as_ref()
|
||||
.map(|p| p.constraints.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let files = picker.get_files();
|
||||
let result = grep::multi_grep_search(files, &patterns_refs, constraints, &options);
|
||||
let file_refs: Vec<&FileItem> = result.files.to_vec();
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Fallback: try individual patterns with plain grep
|
||||
let (fallback_options, _) =
|
||||
make_grep_options(output_mode, GrepMode::PlainText, 0, params.context);
|
||||
|
||||
let fallback_options = GrepSearchOptions {
|
||||
time_budget_ms: 3000,
|
||||
before_context: 0,
|
||||
..fallback_options
|
||||
};
|
||||
|
||||
for pat in ¶ms.patterns {
|
||||
let full_query: Cow<str> = if !constraint_query.is_empty() {
|
||||
Cow::Owned(format!("{} {}", constraint_query, pat))
|
||||
} else {
|
||||
Cow::Borrowed(pat)
|
||||
};
|
||||
|
||||
let parsed = parser.parse(&full_query);
|
||||
let fb_result =
|
||||
grep::grep_search(files, &full_query, parsed.as_ref(), &fallback_options);
|
||||
|
||||
if !fb_result.matches.is_empty() {
|
||||
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &fb_result.matches,
|
||||
files: &fb_file_refs,
|
||||
total_matched: fb_result.matches.len(),
|
||||
next_file_offset: fb_result.next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: false,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 multi-pattern matches. Plain grep fallback for \"{}\":\n{}",
|
||||
pat, text
|
||||
))]));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
if result.matches.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &result.matches,
|
||||
files: &file_refs,
|
||||
total_matched: result.matches.len(),
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(text)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for FffServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let notice = crate::update_check::get_update_notice();
|
||||
let instructions = if notice.is_empty() {
|
||||
crate::MCP_INSTRUCTIONS.to_string()
|
||||
} else {
|
||||
format!("{}{}", crate::MCP_INSTRUCTIONS, notice)
|
||||
};
|
||||
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new("fff", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions(instructions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Background update checker — compares the embedded build hash against
|
||||
//! the latest GitHub release tag to surface upgrade notices in MCP instructions.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const REPO: &str = "dmtrKovalenko/fff.nvim";
|
||||
const BUILD_HASH: &str = env!("FFF_GIT_HASH");
|
||||
|
||||
/// Holds the result of the update check (empty string = up to date or check failed).
|
||||
static UPDATE_NOTICE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Returns the update notice if the check has completed, empty string otherwise.
|
||||
pub fn get_update_notice() -> &'static str {
|
||||
UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Kick off the update check in a background thread so it never blocks the server.
|
||||
pub fn spawn_update_check() {
|
||||
std::thread::spawn(|| {
|
||||
let notice = check_latest_release();
|
||||
let _ = UPDATE_NOTICE.set(notice);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from GitHub and compare against the build hash.
|
||||
fn check_latest_release() -> String {
|
||||
match fetch_latest_tag() {
|
||||
Ok(tag) => compare_versions(BUILD_HASH, &tag),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare a build hash against a release tag.
|
||||
/// Returns an update notice string, or empty if up-to-date.
|
||||
fn compare_versions(build_hash: &str, release_tag: &str) -> String {
|
||||
let tag = release_tag.trim();
|
||||
if tag.is_empty() || build_hash == "unknown" {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let our_short = &build_hash[..build_hash.len().min(tag.len())];
|
||||
if our_short == tag {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!(
|
||||
"\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell out to curl to fetch the latest release tag name from GitHub API.
|
||||
fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = std::process::Command::new("curl")
|
||||
.args([
|
||||
"-fsSL",
|
||||
"--max-time",
|
||||
"5",
|
||||
"-H",
|
||||
"Accept: application/vnd.github.v3+json",
|
||||
&format!("https://api.github.com/repos/{REPO}/releases?per_page=1"),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("curl failed".into());
|
||||
}
|
||||
|
||||
let body = String::from_utf8(output.stdout)?;
|
||||
let releases: Vec<serde_json::Value> = serde_json::from_str(&body)?;
|
||||
let tag = releases
|
||||
.first()
|
||||
.and_then(|r| r.get("tag_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_check_end_to_end() {
|
||||
// Fetch the actual latest release tag from GitHub
|
||||
let tag = fetch_latest_tag().expect("Failed to fetch latest release tag from GitHub");
|
||||
assert!(!tag.is_empty(), "Release tag should not be empty");
|
||||
|
||||
let notice = compare_versions(BUILD_HASH, &tag);
|
||||
let build_short = &BUILD_HASH[..BUILD_HASH.len().min(7)];
|
||||
|
||||
if BUILD_HASH.starts_with(tag.trim()) || tag.trim().starts_with(BUILD_HASH) {
|
||||
// If by chance we're on the exact release commit
|
||||
assert!(notice.is_empty(), "Should be empty when hashes match");
|
||||
} else {
|
||||
assert!(
|
||||
notice.contains("fff update available"),
|
||||
"Expected update notice for mismatched hashes (build: {}, release: {}), got: '{}'",
|
||||
build_short,
|
||||
tag.trim(),
|
||||
notice
|
||||
);
|
||||
assert!(
|
||||
notice.contains(tag.trim()),
|
||||
"Notice should contain release tag"
|
||||
);
|
||||
assert!(
|
||||
notice.contains(build_short),
|
||||
"Notice should contain our short hash"
|
||||
);
|
||||
assert!(
|
||||
notice.contains("install-mcp.sh"),
|
||||
"Notice should contain install command"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,16 @@ edition = "2024"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "jemalloc_profile"
|
||||
name = "jemalloc_profile"
|
||||
path = "src/bin/jemalloc_profile.rs"
|
||||
|
||||
[[bin]]
|
||||
@@ -23,6 +27,14 @@ path = "src/bin/search_profiler.rs"
|
||||
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 }
|
||||
@@ -46,16 +58,13 @@ heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
mimalloc = "0.1.47"
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.7.2" }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.6"
|
||||
once_cell = "1.20.2"
|
||||
pathdiff = "0.2.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = "1.2.8"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
@@ -70,7 +79,3 @@ harness = false
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
# On Windows, git2 uses the native SChannel TLS backend
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::FILE_PICKER;
|
||||
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
|
||||
use fff_nvim::types::PaginationArgs;
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Initialize tracing to output to console
|
||||
@@ -19,20 +20,27 @@ fn init_tracing() {
|
||||
// .try_init();
|
||||
}
|
||||
|
||||
/// Initialize FilePicker and insert into global state
|
||||
fn init_file_picker_internal(path: &str) -> Result<(), String> {
|
||||
let picker = FilePicker::new(path.to_string())
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
|
||||
|
||||
let mut picker_guard = FILE_PICKER
|
||||
.write()
|
||||
.map_err(|_| "Failed to acquire write lock")?;
|
||||
*picker_guard = Some(picker);
|
||||
Ok(())
|
||||
/// Initialize FilePicker using shared state
|
||||
fn init_file_picker_internal(
|
||||
path: &str,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), String> {
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(shared_picker),
|
||||
Arc::clone(shared_frecency),
|
||||
)
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
|
||||
}
|
||||
|
||||
/// Helper function to wait for scanning to complete and get file count
|
||||
fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
|
||||
fn wait_for_scan_completion(
|
||||
shared_picker: &SharedPicker,
|
||||
timeout_secs: u64,
|
||||
) -> Result<usize, String> {
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut last_log = std::time::Instant::now();
|
||||
@@ -42,7 +50,7 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
|
||||
iteration += 1;
|
||||
|
||||
{
|
||||
let picker_guard = FILE_PICKER
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -91,9 +99,9 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files from the global FILE_PICKER
|
||||
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
let picker_guard = FILE_PICKER
|
||||
/// Get files from the shared picker
|
||||
fn get_files_snapshot(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -103,9 +111,9 @@ fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up global state
|
||||
fn cleanup_global_state() {
|
||||
if let Ok(mut picker_guard) = FILE_PICKER.write() {
|
||||
/// Clean up shared state
|
||||
fn cleanup_shared_state(shared_picker: &SharedPicker) {
|
||||
if let Ok(mut picker_guard) = shared_picker.write() {
|
||||
if let Some(mut picker) = picker_guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
@@ -113,7 +121,7 @@ fn cleanup_global_state() {
|
||||
}
|
||||
|
||||
/// Initialize FilePicker once and return files snapshot
|
||||
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String> {
|
||||
init_tracing();
|
||||
|
||||
let big_repo_path = PathBuf::from("./big-repo");
|
||||
@@ -121,37 +129,28 @@ fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&big_repo_path)
|
||||
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
eprintln!(" Path: {:?}", canonical_path);
|
||||
|
||||
{
|
||||
let picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let files = picker.get_files();
|
||||
if !files.is_empty() {
|
||||
eprintln!(" ℹ Reusing existing index with {} files", files.len());
|
||||
return Ok(files.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
cleanup_global_state();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
init_file_picker_internal(&canonical_path.to_string_lossy())?;
|
||||
init_file_picker_internal(
|
||||
&canonical_path.to_string_lossy(),
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
)?;
|
||||
|
||||
eprintln!(" Waiting for background scan to complete...");
|
||||
let file_count = wait_for_scan_completion(120)?;
|
||||
let file_count = wait_for_scan_completion(&shared_picker, 120)?;
|
||||
eprintln!(
|
||||
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
|
||||
file_count
|
||||
);
|
||||
|
||||
get_files_snapshot()
|
||||
let files = get_files_snapshot(&shared_picker)?;
|
||||
Ok((files, shared_picker, shared_frecency))
|
||||
}
|
||||
|
||||
/// Benchmark for indexing the big-repo directory
|
||||
@@ -166,7 +165,7 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = match big_repo_path.canonicalize() {
|
||||
let canonical_path = match fff_core::path_utils::canonicalize(&big_repo_path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Failed to canonicalize path: {}", e);
|
||||
@@ -180,21 +179,23 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("index_big_repo", |b| {
|
||||
b.iter(|| {
|
||||
cleanup_global_state();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let sp: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let sf: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
|
||||
.expect("Failed to init FilePicker");
|
||||
|
||||
match wait_for_scan_completion(120) {
|
||||
match wait_for_scan_completion(&sp, 120) {
|
||||
Ok(file_count) => {
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
|
||||
cleanup_shared_state(&sp);
|
||||
file_count
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Error: {}", e);
|
||||
cleanup_shared_state(&sp);
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -206,8 +207,8 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark for searching with various query patterns
|
||||
fn bench_search_queries(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprint!("Failed to setup picker {e:?}");
|
||||
return;
|
||||
@@ -231,6 +232,7 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -255,8 +257,8 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search with different thread counts
|
||||
fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
|
||||
return;
|
||||
@@ -278,6 +280,7 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: threads,
|
||||
current_file: None,
|
||||
@@ -303,8 +306,8 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search with different result limits
|
||||
fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
|
||||
return;
|
||||
@@ -323,6 +326,7 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -347,8 +351,8 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search algorithm performance scaling with file count
|
||||
fn bench_search_scalability(c: &mut Criterion) {
|
||||
let all_files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (all_files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
|
||||
return;
|
||||
@@ -380,6 +384,7 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(subset),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -404,8 +409,8 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search performance with different ordering modes
|
||||
fn bench_search_ordering(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
|
||||
return;
|
||||
@@ -423,6 +428,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -447,6 +453,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -471,6 +478,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -494,6 +502,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -518,6 +527,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -541,6 +551,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -564,8 +575,8 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark pagination: first page vs deep page
|
||||
fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
|
||||
return;
|
||||
@@ -584,6 +595,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -608,6 +620,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -632,6 +645,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::query_tracker::QueryTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -13,9 +13,8 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
let canonical_path =
|
||||
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
eprintln!("Loading files from: {:?}", canonical_path);
|
||||
|
||||
@@ -38,19 +37,15 @@ fn main() {
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
files.push(FileItem {
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
relative_path,
|
||||
file_name_lower: file_name.to_lowercase(),
|
||||
file_name,
|
||||
size: entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
modified: 0,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status: None,
|
||||
});
|
||||
entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed.as_ref(), &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,485 @@
|
||||
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::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
|
||||
.for_each(|entry| {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
is_binary,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
}
|
||||
|
||||
struct BenchStats {
|
||||
times: Vec<Duration>,
|
||||
}
|
||||
|
||||
impl BenchStats {
|
||||
fn new() -> Self {
|
||||
Self { times: Vec::new() }
|
||||
}
|
||||
|
||||
fn push(&mut self, d: Duration) {
|
||||
self.times.push(d);
|
||||
}
|
||||
|
||||
fn mean(&self) -> Duration {
|
||||
let total: Duration = self.times.iter().sum();
|
||||
total / self.times.len() as u32
|
||||
}
|
||||
|
||||
fn median(&self) -> Duration {
|
||||
let mut sorted = self.times.clone();
|
||||
sorted.sort();
|
||||
sorted[sorted.len() / 2]
|
||||
}
|
||||
|
||||
fn p95(&self) -> Duration {
|
||||
let mut sorted = self.times.clone();
|
||||
sorted.sort();
|
||||
let idx = ((sorted.len() as f64) * 0.95) as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
fn p99(&self) -> Duration {
|
||||
let mut sorted = self.times.clone();
|
||||
sorted.sort();
|
||||
let idx = ((sorted.len() as f64) * 0.99) as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
fn min(&self) -> Duration {
|
||||
*self.times.iter().min().unwrap()
|
||||
}
|
||||
|
||||
fn max(&self) -> Duration {
|
||||
*self.times.iter().max().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
struct GrepBench<'a> {
|
||||
files: &'a [FileItem],
|
||||
options: GrepSearchOptions,
|
||||
}
|
||||
|
||||
impl<'a> GrepBench<'a> {
|
||||
fn new(files: &'a [FileItem]) -> Self {
|
||||
Self::with_mode(files, GrepMode::PlainText)
|
||||
}
|
||||
|
||||
fn with_mode(files: &'a [FileItem], mode: GrepMode) -> Self {
|
||||
Self {
|
||||
files,
|
||||
options: GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single grep search, return (duration, match_count, files_searched)
|
||||
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(self.files, query, parsed.as_ref(), &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/7] Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let load_time = load_start.elapsed();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let large_files = files.iter().filter(|f| f.size > 10 * 1024 * 1024).count();
|
||||
eprintln!(
|
||||
" Loaded {} files in {:.2}s ({} non-binary, {} >10MB skipped)\n",
|
||||
files.len(),
|
||||
load_time.as_secs_f64(),
|
||||
non_binary,
|
||||
large_files,
|
||||
);
|
||||
|
||||
let bench = GrepBench::new(&files);
|
||||
|
||||
eprintln!("[2/7] Cold cache benchmarks (first search, mmap not yet loaded)");
|
||||
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
|
||||
print_header();
|
||||
|
||||
let cold_queries: Vec<(&str, &str)> = vec![
|
||||
("cold_common_2char", "if"),
|
||||
("cold_common_word", "return"),
|
||||
("cold_specific_func", "mutex_lock"),
|
||||
("cold_struct_name", "inode_operations"),
|
||||
("cold_define", "MODULE_LICENSE"),
|
||||
("cold_rare_string", "phylink_ethtool"),
|
||||
("cold_path_filter", "printk *.c"),
|
||||
("cold_long_query", "static int __init"),
|
||||
];
|
||||
|
||||
for (name, query) in &cold_queries {
|
||||
// Re-load files to get fresh FileItems with no cached mmaps
|
||||
let fresh_files = load_files(&canonical);
|
||||
let fresh_bench = GrepBench::new(&fresh_files);
|
||||
let (stats, matches, files_searched) = fresh_bench.bench_query(query, 1);
|
||||
print_row(name, &stats, matches, files_searched, 1);
|
||||
}
|
||||
|
||||
eprintln!("\n[3/7] Warm cache benchmarks (plain text, mmap cache populated)");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
let warm_queries: Vec<(&str, &str, usize)> = vec![
|
||||
("warm_2char", "if", 10),
|
||||
("warm_common_word", "return", 10),
|
||||
("warm_function_call", "mutex_lock", 15),
|
||||
("warm_struct_name", "inode_operations", 15),
|
||||
("warm_define", "MODULE_LICENSE", 15),
|
||||
("warm_rare_string", "phylink_ethtool", 20),
|
||||
("warm_include", "#include", 10),
|
||||
("warm_comment", "TODO", 15),
|
||||
("warm_type_decl", "struct file", 15),
|
||||
("warm_error_path", "err = -EINVAL", 15),
|
||||
("warm_long_pattern", "static int __init", 15),
|
||||
("warm_very_common", "int", 10),
|
||||
("warm_single_char", "x", 10),
|
||||
("warm_path_constraint", "printk *.c", 15),
|
||||
("warm_dir_constraint", "mutex /kernel/", 15),
|
||||
];
|
||||
|
||||
// Warmup pass - populate mmap cache
|
||||
for (_, query, _) in &warm_queries {
|
||||
for _ in 0..3 {
|
||||
bench.run_once(query);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, query, iters) in &warm_queries {
|
||||
let (stats, matches, files_searched) = bench.bench_query(query, *iters);
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
|
||||
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
let fuzzy_bench = GrepBench::with_mode(&files, GrepMode::Fuzzy);
|
||||
|
||||
let fuzzy_queries: Vec<(&str, &str, usize)> = vec![
|
||||
("fuzzy_exact", "mutex_lock", 15),
|
||||
("fuzzy_typo", "mutx_lock", 15),
|
||||
("fuzzy_camel", "InodeOps", 15),
|
||||
("fuzzy_abbrev", "sched_rt", 15),
|
||||
("fuzzy_short", "kfr", 15),
|
||||
("fuzzy_common", "return", 10),
|
||||
("fuzzy_define", "MODULE_LICENSE", 15),
|
||||
("fuzzy_struct", "file_operations", 15),
|
||||
("fuzzy_long", "static_int_init", 15),
|
||||
("fuzzy_path", "printk *.c", 15),
|
||||
];
|
||||
|
||||
// Warmup
|
||||
for (_, query, _) in &fuzzy_queries {
|
||||
for _ in 0..3 {
|
||||
fuzzy_bench.run_once(query);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, query, iters) in &fuzzy_queries {
|
||||
let (stats, matches, files_searched) = fuzzy_bench.bench_query(query, *iters);
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy incremental typing ────────────────────────────────────────
|
||||
eprintln!("\n[5/7] Fuzzy incremental typing simulation");
|
||||
eprintln!(" Simulates user typing character by character (fuzzy mode).\n");
|
||||
|
||||
let fuzzy_typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
vec![
|
||||
"m",
|
||||
"mu",
|
||||
"mut",
|
||||
"mute",
|
||||
"mutex",
|
||||
"mutex_",
|
||||
"mutex_l",
|
||||
"mutex_lo",
|
||||
"mutex_loc",
|
||||
"mutex_lock",
|
||||
],
|
||||
),
|
||||
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
|
||||
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
|
||||
];
|
||||
|
||||
for (name, sequence) in &fuzzy_typing_sequences {
|
||||
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
"Query", "Latency", "Match", "Files"
|
||||
);
|
||||
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
|
||||
|
||||
for prefix in sequence {
|
||||
let (elapsed, matches, files_searched) = fuzzy_bench.run_once(prefix);
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
format!("\"{}\"", prefix),
|
||||
fmt_dur(elapsed),
|
||||
matches,
|
||||
files_searched,
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[6/7] Incremental typing simulation (plain text)");
|
||||
eprintln!(" Simulates user typing character by character.\n");
|
||||
|
||||
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
vec![
|
||||
"m",
|
||||
"mu",
|
||||
"mut",
|
||||
"mute",
|
||||
"mutex",
|
||||
"mutex_",
|
||||
"mutex_l",
|
||||
"mutex_lo",
|
||||
"mutex_loc",
|
||||
"mutex_lock",
|
||||
],
|
||||
),
|
||||
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
|
||||
("inode", vec!["i", "in", "ino", "inod", "inode"]),
|
||||
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
|
||||
];
|
||||
|
||||
for (name, sequence) in &typing_sequences {
|
||||
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
"Query", "Latency", "Match", "Files"
|
||||
);
|
||||
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
|
||||
|
||||
for prefix in sequence {
|
||||
let (elapsed, matches, files_searched) = bench.run_once(prefix);
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
format!("\"{}\"", prefix),
|
||||
fmt_dur(elapsed),
|
||||
matches,
|
||||
files_searched,
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[7/7] Pagination benchmark");
|
||||
eprintln!(" Testing page_offset performance for common query.\n");
|
||||
|
||||
let pagination_query = "return";
|
||||
eprintln!(" Query: \"{}\"", pagination_query);
|
||||
eprintln!(
|
||||
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
|
||||
"Page", "File offset", "Latency", "Matches", "Next offset"
|
||||
);
|
||||
eprintln!(
|
||||
" {:-<6}-+-{:-<12}-+-{:-<8}-+-{:-<6}-+-{:-<12}",
|
||||
"", "", "", "", ""
|
||||
);
|
||||
|
||||
let mut file_offset = 0usize;
|
||||
for page in 0..10 {
|
||||
let parsed = parse_grep_query(pagination_query);
|
||||
let opts = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(&files, pagination_query, parsed.as_ref(), &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,500 @@
|
||||
use fff_core::FFFQuery;
|
||||
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,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed.as_ref(), &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn benchmark_fff_smart_case(
|
||||
files: &[FileItem],
|
||||
query: &str,
|
||||
parsed: Option<FFFQuery<'_>>,
|
||||
) -> (usize, Duration) {
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: usize::MAX,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 5000,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed.as_ref(), &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
/// fff paginated: first 50 results only (real UI scenario).
|
||||
fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed.as_ref(), &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!();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -60,6 +61,7 @@ fn format_bytes(bytes: usize) -> String {
|
||||
}
|
||||
|
||||
fn test_search_memory_pattern(
|
||||
shared_picker: &SharedPicker,
|
||||
name: &str,
|
||||
iterations: usize,
|
||||
query_pattern: impl Fn(usize) -> String,
|
||||
@@ -82,8 +84,8 @@ fn test_search_memory_pattern(
|
||||
let query = query_pattern(i);
|
||||
|
||||
let (result_count, _total_matched) = {
|
||||
let file_picker_guard = FILE_PICKER.read().unwrap();
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(&query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
@@ -177,20 +179,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize FilePicker
|
||||
{
|
||||
let mut file_picker_guard = FILE_PICKER.write().unwrap();
|
||||
if file_picker_guard.is_none() {
|
||||
println!("Initializing FilePicker...");
|
||||
*file_picker_guard = Some(FilePicker::new(base_path.clone())?);
|
||||
}
|
||||
}
|
||||
println!("Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Wait for initial scan
|
||||
println!("Waiting for file scan...");
|
||||
loop {
|
||||
if let Ok(file_picker_guard) = FILE_PICKER.read()
|
||||
&& let Some(ref picker) = *file_picker_guard
|
||||
if let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
&& !picker.is_scan_active()
|
||||
&& !picker.get_files().is_empty()
|
||||
{
|
||||
@@ -200,8 +207,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let file_count = {
|
||||
let file_picker_guard = FILE_PICKER.read()?;
|
||||
file_picker_guard.as_ref().unwrap().get_files().len()
|
||||
let guard = shared_picker.read().unwrap();
|
||||
guard.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
println!("📊 Found {} files", file_count);
|
||||
@@ -216,10 +223,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Test different memory patterns
|
||||
|
||||
// 1. Repeated same query - should have minimal growth if caching works
|
||||
test_search_memory_pattern("Same Query Repeated (1000x)", 1000, |_| "test".to_string())?;
|
||||
test_search_memory_pattern(&shared_picker, "Same Query Repeated (1000x)", 1000, |_| {
|
||||
"test".to_string()
|
||||
})?;
|
||||
|
||||
// 2. Cycling through different queries
|
||||
test_search_memory_pattern("Cycling Queries (1000x)", 1000, |i| {
|
||||
test_search_memory_pattern(&shared_picker, "Cycling Queries (1000x)", 1000, |i| {
|
||||
let queries = [
|
||||
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
|
||||
];
|
||||
@@ -227,24 +236,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
})?;
|
||||
|
||||
// 3. Unique queries each time - worst case for any caching
|
||||
test_search_memory_pattern("Unique Queries (500x)", 500, |i| {
|
||||
test_search_memory_pattern(&shared_picker, "Unique Queries (500x)", 500, |i| {
|
||||
format!("unique_query_{}", i)
|
||||
})?;
|
||||
|
||||
// 4. Queries that return many results
|
||||
test_search_memory_pattern(
|
||||
&shared_picker,
|
||||
"High Result Count (500x)",
|
||||
500,
|
||||
|_| "a".to_string(), // Single character likely to match many files
|
||||
)?;
|
||||
|
||||
// 5. Queries with no results
|
||||
test_search_memory_pattern("No Results (500x)", 500, |_| {
|
||||
test_search_memory_pattern(&shared_picker, "No Results (500x)", 500, |_| {
|
||||
"zzzz_no_match_expected".to_string()
|
||||
})?;
|
||||
|
||||
// 6. Long intensive test
|
||||
test_search_memory_pattern("Long Intensive Test (2000x)", 2000, |i| {
|
||||
test_search_memory_pattern(&shared_picker, "Long Intensive Test (2000x)", 2000, |i| {
|
||||
let patterns = [
|
||||
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
|
||||
];
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::{
|
||||
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
|
||||
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut iteration = 0;
|
||||
@@ -11,7 +14,7 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
let picker_guard = FILE_PICKER
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -45,21 +48,9 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize FilePicker and insert into global state
|
||||
fn init_file_picker(path: &str) -> Result<(), String> {
|
||||
let picker = FilePicker::new(path.to_string())
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
|
||||
|
||||
let mut picker_guard = FILE_PICKER
|
||||
.write()
|
||||
.map_err(|_| "Failed to acquire write lock")?;
|
||||
*picker_guard = Some(picker);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get files snapshot from global state
|
||||
fn get_files() -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = FILE_PICKER
|
||||
/// Get files snapshot from shared state
|
||||
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -79,21 +70,31 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
let canonical_path =
|
||||
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
|
||||
FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
.expect("Failed to init FilePicker");
|
||||
|
||||
// Give background thread time to start
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
eprintln!("Waiting for scan to complete...");
|
||||
let file_count = wait_for_scan(120).expect("Failed to wait for scan");
|
||||
let file_count = wait_for_scan(&shared_picker, 120).expect("Failed to wait for scan");
|
||||
eprintln!("✓ Indexed {} files\n", file_count);
|
||||
|
||||
let files = get_files().expect("Failed to get files");
|
||||
let files = get_files(&shared_picker).expect("Failed to get files");
|
||||
|
||||
// Test queries representing different search patterns
|
||||
let test_queries = vec![
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -77,24 +78,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// Initialize the file picker directly
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize the file picker
|
||||
println!("📁 Initializing FilePicker...");
|
||||
{
|
||||
let mut file_picker_guard = FILE_PICKER.write().unwrap();
|
||||
if file_picker_guard.is_none() {
|
||||
println!("Creating new FilePicker for path: {}", base_path);
|
||||
match FilePicker::new(base_path.clone()) {
|
||||
Ok(picker) => {
|
||||
println!("FilePicker created successfully");
|
||||
*file_picker_guard = Some(picker);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create FilePicker: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Wait for initial scan to complete
|
||||
println!("⏳ Waiting for initial file scan to complete...");
|
||||
@@ -102,8 +98,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut scan_completed = false;
|
||||
|
||||
loop {
|
||||
if let Ok(file_picker_guard) = FILE_PICKER.read()
|
||||
&& let Some(ref picker) = *file_picker_guard
|
||||
if let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
if !picker.is_scan_active() {
|
||||
println!("Scan inactive, checking file count...");
|
||||
@@ -128,10 +124,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// If async scan didn't work, trigger a manual scan
|
||||
if !scan_completed {
|
||||
println!("Triggering manual rescan...");
|
||||
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
match picker.trigger_rescan() {
|
||||
match picker.trigger_rescan(&shared_frecency) {
|
||||
Ok(_) => println!("Manual rescan completed"),
|
||||
Err(e) => println!("Manual rescan failed: {:?}", e),
|
||||
}
|
||||
@@ -139,8 +135,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let initial_file_count = {
|
||||
let file_picker_guard = FILE_PICKER.read()?;
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let files = picker.get_files();
|
||||
println!("Found {} files in picker", files.len());
|
||||
if !files.is_empty() {
|
||||
@@ -151,7 +147,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
files.len()
|
||||
} else {
|
||||
println!("No picker found in FILE_PICKER static!");
|
||||
println!("No picker found!");
|
||||
0
|
||||
}
|
||||
};
|
||||
@@ -199,8 +195,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let search_start = Instant::now();
|
||||
let parser = QueryParser::default();
|
||||
let (result_count, search_duration) = {
|
||||
let file_picker_guard = FILE_PICKER.read().unwrap();
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let parsed = parser.parse(query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
|
||||
@@ -4,33 +4,16 @@
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FILE_PICKER, FRECENCY, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{
|
||||
FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn cleanup_global_state() {
|
||||
// Clean up file picker
|
||||
{
|
||||
let mut file_picker = FILE_PICKER.write().unwrap();
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
drop(picker);
|
||||
println!("🧹 FilePicker cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up frecency tracker
|
||||
{
|
||||
let mut frecency = FRECENCY.write().unwrap();
|
||||
*frecency = None;
|
||||
println!("🧹 Frecency tracker cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let base_path = if args.len() > 1 {
|
||||
@@ -43,28 +26,39 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Clone for signal handler
|
||||
let picker_for_cleanup = Arc::clone(&shared_picker);
|
||||
ctrlc::set_handler(move || {
|
||||
println!("\n🛑 Received interrupt signal, shutting down...");
|
||||
cleanup_global_state();
|
||||
if let Ok(mut guard) = picker_for_cleanup.write() {
|
||||
if let Some(mut picker) = guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
println!("🧹 FilePicker cleaned up");
|
||||
}
|
||||
}
|
||||
r.store(false, Ordering::SeqCst);
|
||||
std::process::exit(0);
|
||||
})?;
|
||||
|
||||
let mut git_stats = std::collections::HashMap::new();
|
||||
// Initialize the global file picker using lib.rs function
|
||||
{
|
||||
let mut file_picker = FILE_PICKER.write().unwrap();
|
||||
if file_picker.is_some() {
|
||||
eprintln!("❌ FilePicker already initialized");
|
||||
std::process::exit(1);
|
||||
}
|
||||
*file_picker = Some(FilePicker::new(base_path.clone())?);
|
||||
}
|
||||
|
||||
// Get initial file count from global state
|
||||
// Initialize the file picker using shared state
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::default(),
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Get initial file count from shared state
|
||||
let initial_count = {
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
println!("Initial file count: {}", files.len());
|
||||
|
||||
if !files.is_empty() {
|
||||
@@ -96,8 +90,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
iteration += 1;
|
||||
|
||||
let current_count = {
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
file_picker.as_ref().unwrap().get_files().len()
|
||||
let guard = shared_picker.read().unwrap();
|
||||
guard.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
if current_count != last_count {
|
||||
@@ -111,13 +105,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
|
||||
// Show some recently added files
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let newest_files = files.iter().rev().take(added.min(3));
|
||||
for file in newest_files {
|
||||
println!(" ➕ {}", file.relative_path);
|
||||
}
|
||||
drop(file_picker);
|
||||
} else {
|
||||
let removed = last_count - current_count;
|
||||
println!(
|
||||
@@ -136,8 +129,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
timestamp, current_count
|
||||
);
|
||||
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let current_files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let current_files = guard.as_ref().unwrap().get_files();
|
||||
|
||||
git_stats.clear();
|
||||
for file in current_files {
|
||||
@@ -156,8 +149,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if iteration % 40 == 0 {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S");
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse("rs");
|
||||
let search_results = FilePicker::fuzzy_search(
|
||||
@@ -197,13 +190,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
score.total
|
||||
);
|
||||
}
|
||||
drop(file_picker);
|
||||
}
|
||||
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
// Clean up before exit
|
||||
cleanup_global_state();
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(mut picker) = guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+199
-32
@@ -3,12 +3,16 @@ use error::{IntoCoreError, IntoLuaResult};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
|
||||
use fff_core::{
|
||||
DbHealthChecker, Error, FFFMode, 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;
|
||||
@@ -19,6 +23,12 @@ mod path_shortening;
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
// the global state for neovim lives here for efficiency
|
||||
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
|
||||
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
|
||||
pub fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
@@ -33,6 +43,10 @@ pub fn init_db(
|
||||
*frecency =
|
||||
Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock).into_lua_result()?);
|
||||
tracing::info!("Frecency database initialized at {}", frecency_db_path);
|
||||
drop(frecency);
|
||||
|
||||
// Spawn background GC to purge stale entries without blocking startup
|
||||
FrecencyTracker::spawn_gc(Arc::clone(&FRECENCY), frecency_db_path, use_unsafe_no_lock);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
@@ -68,31 +82,54 @@ pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if file_picker.is_some() {
|
||||
return Ok(false);
|
||||
{
|
||||
let guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if guard.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let picker = FilePicker::new(base_path).into_lua_result()?;
|
||||
*file_picker = Some(picker);
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)
|
||||
.into_lua_result()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
|
||||
// drop should clean it anyway but just to be extra sure
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
// Cancel and stop the old picker under a single write lock to avoid
|
||||
// a window where FILE_PICKER is None (which causes FilePickerMissing
|
||||
// errors if the UI is searching concurrently).
|
||||
{
|
||||
let mut guard = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
picker.cancel();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Don't take() here — leave the old picker in place so searches
|
||||
// still work until new_with_shared_state replaces it atomically.
|
||||
}
|
||||
|
||||
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
|
||||
*file_picker = Some(new_picker);
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string_lossy().to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -106,10 +143,16 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = path.canonicalize().map_err(|e| {
|
||||
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) {
|
||||
@@ -136,7 +179,7 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
|
||||
picker.trigger_rescan().into_lua_result()?;
|
||||
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
Ok(())
|
||||
}
|
||||
@@ -226,6 +269,63 @@ pub fn fuzzy_search_files(
|
||||
lua_types::SearchResultLua::from(results).into_lua(lua)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn live_grep(
|
||||
lua: &Lua,
|
||||
(
|
||||
query,
|
||||
file_offset,
|
||||
page_size,
|
||||
max_file_size,
|
||||
max_matches_per_file,
|
||||
smart_case,
|
||||
grep_mode,
|
||||
time_budget_ms,
|
||||
): (
|
||||
String,
|
||||
Option<usize>,
|
||||
Option<usize>,
|
||||
Option<u64>,
|
||||
Option<usize>,
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
Option<u64>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let parsed = fff_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),
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed.as_ref(), &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);
|
||||
|
||||
@@ -308,7 +408,7 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status_global().into_lua_result()
|
||||
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
@@ -381,7 +481,7 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
};
|
||||
|
||||
// Canonicalize the file path before spawning thread
|
||||
let file_path = match PathBuf::from(&file_path).canonicalize() {
|
||||
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");
|
||||
@@ -390,8 +490,9 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -431,22 +532,82 @@ pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>>
|
||||
.into_lua_result()
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
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::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.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 picker.is_scan_active() {
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
@@ -628,6 +789,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
"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)?)?;
|
||||
@@ -658,6 +820,11 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
"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)?)?;
|
||||
|
||||
|
||||
+2
-151
@@ -1,152 +1,3 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
//! Logging setup for fff-nvim — delegates to the shared fff-core::log utilities.
|
||||
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install panic hook that writes to both stderr and a fallback file
|
||||
/// This is called separately from init_tracing to ensure panics are always logged
|
||||
pub fn install_panic_hook() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF.nvim"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF.nvim PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("======================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_nvim_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
}
|
||||
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize tracing with single log file
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true) // creates a new file on every setup
|
||||
.open(log_path)?;
|
||||
|
||||
let level = match log_level
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
Some("debug") => tracing::Level::DEBUG,
|
||||
Some("info") => tracing::Level::INFO,
|
||||
Some("warn") => tracing::Level::WARN,
|
||||
Some("error") => tracing::Level::ERROR,
|
||||
_ => tracing::Level::INFO,
|
||||
};
|
||||
|
||||
TRACING_INITIALIZED.get_or_init(|| {
|
||||
let (non_blocking_appender, guard) = non_blocking(file_appender);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF.nvim tracing initialized with log file: {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
}
|
||||
pub use fff_core::log::{init_tracing, install_panic_hook};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! This module provides IntoLua implementations for core types.
|
||||
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FileItem, Location, Score, SearchResult};
|
||||
use fff_core::{FileItem, GrepResult, Location, Score, SearchResult};
|
||||
use mlua::prelude::*;
|
||||
|
||||
/// Wrapper for SearchResult that implements IntoLua
|
||||
@@ -17,6 +17,17 @@ impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -42,6 +53,7 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
)?;
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -104,3 +116,68 @@ impl IntoLua for SearchResultLua<'_> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ impl PathCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size), level = tracing::Level::TRACE)]
|
||||
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
|
||||
self.map.get(path).and_then(|entry| {
|
||||
// Only return cached value if max_size matches
|
||||
@@ -85,7 +85,7 @@ pub fn shorten_path_with_cache(
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
|
||||
if let Some(cached) = cache.get(path, max_size) {
|
||||
tracing::debug!("Cache hit for path '{}'", path.display());
|
||||
tracing::trace!("Cache hit for path '{}'", path.display());
|
||||
return Ok(cached.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ edition = "2024"
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["dep:zlob"]
|
||||
|
||||
[dependencies]
|
||||
smallvec = { workspace = true }
|
||||
zlob = { version = "1.2.8" }
|
||||
zlob = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -1,4 +1,46 @@
|
||||
use crate::constraints::Constraint;
|
||||
use crate::glob_detect::has_wildcards;
|
||||
|
||||
/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
|
||||
///
|
||||
/// A token is a filename/path if ALL of:
|
||||
/// - Does NOT end with `/` (that's a directory/PathSegment)
|
||||
/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
|
||||
/// - Last component (after final `/`) contains `.` with a valid-looking extension
|
||||
/// (1–10 alphanumeric chars starting with a letter, e.g. `rs`, `json`, `tsx`)
|
||||
///
|
||||
/// This covers both bare filenames (`score.rs`) and path-prefixed ones (`src/main.rs`).
|
||||
#[inline]
|
||||
fn is_filename_constraint_token(token: &str) -> bool {
|
||||
let bytes = token.as_bytes();
|
||||
|
||||
// Must NOT end with / (that's a PathSegment)
|
||||
if bytes.last() == Some(&b'/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must NOT contain wildcards (those are globs)
|
||||
if has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the filename component (after last /)
|
||||
let filename = token.rsplit('/').next().unwrap_or(token);
|
||||
|
||||
// Extension must exist and look like a real file extension:
|
||||
// starts with an ASCII letter (rejects version numbers like "v2.0"),
|
||||
// followed by alphanumeric chars, max 10 chars total.
|
||||
match filename.rfind('.') {
|
||||
Some(dot_pos) => {
|
||||
let ext = &filename[dot_pos + 1..];
|
||||
!ext.is_empty()
|
||||
&& ext.len() <= 10
|
||||
&& ext.as_bytes()[0].is_ascii_alphabetic()
|
||||
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parser configuration trait - allows different picker types to customize parsing
|
||||
pub trait ParserConfig {
|
||||
@@ -31,6 +73,17 @@ pub trait ParserConfig {
|
||||
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
|
||||
@@ -42,22 +95,29 @@ pub trait ParserConfig {
|
||||
pub struct FilePickerConfig;
|
||||
|
||||
impl ParserConfig for FilePickerConfig {
|
||||
// All defaults enabled
|
||||
/// Detect bare filenames (`score.rs`) and path-prefixed filenames (`src/main.rs`)
|
||||
/// as `FilePath` constraints so that multi-token queries like `score.rs file_picker`
|
||||
/// filter by filename first, then fuzzy-match the remaining text against the path.
|
||||
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
|
||||
if is_filename_constraint_token(token) {
|
||||
Some(Constraint::FilePath(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for full-text search (grep) - limited constraints
|
||||
/// 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_extension(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_glob(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -65,4 +125,113 @@ impl ParserConfig for GrepConfig {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
|
||||
/// automatic file-path constraint detection.
|
||||
///
|
||||
/// When an AI agent sends `"libswscale/input.c rgba32ToY"`, the token
|
||||
/// `libswscale/input.c` is detected as a `FilePath` constraint so the
|
||||
/// search is scoped to that file. The caller validates the constraint
|
||||
/// against the index and drops it if no files match (fallback).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AiGrepConfig;
|
||||
|
||||
impl ParserConfig for AiGrepConfig {
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn enable_git_status(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// First check GrepConfig's strict rules (path globs, brace expansion)
|
||||
if GrepConfig.is_glob_pattern(token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
|
||||
// Recognise tokens that start AND end with `*` with non-empty text
|
||||
// between them as glob constraints. Bare `*` or `**` are excluded.
|
||||
if !has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
let bytes = token.as_bytes();
|
||||
if bytes.len() >= 3
|
||||
&& bytes[0] == b'*'
|
||||
&& bytes[bytes.len() - 1] == b'*'
|
||||
&& bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
|
||||
if is_file_path_token(token) {
|
||||
Some(Constraint::FilePath(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_file_path_token(token: &str) -> bool {
|
||||
let bytes = token.as_bytes();
|
||||
|
||||
// Must contain at least one /
|
||||
if !bytes.contains(&b'/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must NOT end with / (that's a PathSegment)
|
||||
if bytes.last() == Some(&b'/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must NOT contain wildcards (those are globs)
|
||||
if has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Last component must contain . (file extension)
|
||||
match token.rsplit('/').next() {
|
||||
Some(last) => last.contains('.'),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ pub enum Constraint<'a> {
|
||||
/// Path constraint: /src/ -> PathSegment("src")
|
||||
PathSegment(&'a str),
|
||||
|
||||
/// File path constraint (AI mode): "libswscale/input.c" → FilePath("libswscale/input.c")
|
||||
/// Matches files whose relative path ends with this suffix at a `/` boundary.
|
||||
FilePath(&'a str),
|
||||
|
||||
/// File type constraint: type:rust -> FileType("rust")
|
||||
FileType(&'a str),
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Glob wildcard detection — delegates to zlob when available, pure-Rust fallback otherwise.
|
||||
//!
|
||||
//! All call sites use a single function: `has_wildcards(text) -> bool`.
|
||||
//! When the `zlob` feature is enabled this calls `zlob::has_wildcards` with
|
||||
//! `ZlobFlags::RECOMMENDED`; without it we check for the same set of wildcard
|
||||
//! characters (`*`, `?`, `[`, `{`) in pure Rust.
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
zlob::has_wildcards(s, zlob::ZlobFlags::RECOMMENDED)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
|
||||
}
|
||||
@@ -41,10 +41,11 @@
|
||||
|
||||
mod config;
|
||||
mod constraints;
|
||||
pub mod glob_detect;
|
||||
pub mod location;
|
||||
mod parser;
|
||||
|
||||
pub use config::{FilePickerConfig, GrepConfig, ParserConfig};
|
||||
pub use config::{AiGrepConfig, FilePickerConfig, GrepConfig, ParserConfig};
|
||||
pub use constraints::{Constraint, GitStatusFilter};
|
||||
pub use location::Location;
|
||||
pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::ConstraintVec;
|
||||
use crate::config::ParserConfig;
|
||||
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
|
||||
use crate::glob_detect::has_wildcards;
|
||||
use crate::location::{Location, parse_location};
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -44,12 +44,17 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
if whitespace_count == 0 {
|
||||
// Try to parse as constraint first
|
||||
if let Some(constraint) = parse_token(query, config) {
|
||||
constraints.push(constraint);
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Empty,
|
||||
location: None,
|
||||
});
|
||||
// Don't treat filename tokens (FilePath) as constraints in single-token
|
||||
// queries — the user is fuzzy-searching, not filtering. FilePath constraints
|
||||
// are only useful as filters in multi-token queries like "score.rs search".
|
||||
if !matches!(constraint, Constraint::FilePath(_)) {
|
||||
constraints.push(constraint);
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Empty,
|
||||
location: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract location from single token (e.g., "file:12")
|
||||
@@ -66,12 +71,23 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Stack-allocated buffer for text parts (up to 16 parts)
|
||||
let mut text_parts = TextPartsBuffer::new();
|
||||
let tokens = query.split_whitespace();
|
||||
|
||||
let mut has_file_path = false;
|
||||
for token in tokens {
|
||||
match parse_token(token, config) {
|
||||
Some(Constraint::FilePath(_)) => {
|
||||
if has_file_path {
|
||||
// Only one FilePath constraint allowed; treat extra path
|
||||
// tokens as literal text (e.g. an import path the user is
|
||||
// searching for).
|
||||
text_parts.push(token);
|
||||
} else {
|
||||
constraints.push(Constraint::FilePath(token));
|
||||
has_file_path = true;
|
||||
}
|
||||
}
|
||||
Some(constraint) => {
|
||||
constraints.push(constraint);
|
||||
}
|
||||
@@ -123,6 +139,47 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FFFQuery<'a> {
|
||||
/// Returns the grep search text by joining all non-constraint text tokens.
|
||||
///
|
||||
/// Backslash-escaped tokens (e.g. `\*.rs`) are included as literal text
|
||||
/// with the leading `\` stripped, since the backslash is only an escape
|
||||
/// signal to the parser and should not appear in the final pattern.
|
||||
///
|
||||
/// `FuzzyQuery::Empty` → empty string
|
||||
/// `FuzzyQuery::Text("foo")` → `"foo"`
|
||||
/// `FuzzyQuery::Parts(["a", "\\*.rs", "b"])` → `"a *.rs b"`
|
||||
pub fn grep_text(&self) -> String {
|
||||
match &self.fuzzy_query {
|
||||
FuzzyQuery::Empty => String::new(),
|
||||
FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
|
||||
FuzzyQuery::Parts(parts) => parts
|
||||
.iter()
|
||||
.map(|t| strip_leading_backslash(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the leading `\` from a backslash-escaped constraint token only.
|
||||
///
|
||||
/// We strip the backslash when the next character is a constraint trigger
|
||||
/// (`*`, `/`, `!`) — the user typed `\*.rs` to mean literal `*.rs`, not an
|
||||
/// extension constraint. For regex escape sequences like `\w`, `\b`, `\d`,
|
||||
/// `\s`, `\n` etc., the backslash is preserved so regex mode works correctly.
|
||||
#[inline]
|
||||
fn strip_leading_backslash(token: &str) -> &str {
|
||||
if token.len() > 1 && token.starts_with('\\') {
|
||||
let next = token.as_bytes()[1];
|
||||
// Only strip if the backslash is escaping a constraint trigger character
|
||||
if next == b'*' || next == b'/' || next == b'!' {
|
||||
return &token[1..];
|
||||
}
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
impl Default for QueryParser<crate::FilePickerConfig> {
|
||||
fn default() -> Self {
|
||||
Self::new(crate::FilePickerConfig)
|
||||
@@ -131,6 +188,12 @@ impl Default for QueryParser<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 {
|
||||
@@ -145,12 +208,12 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
|
||||
// Only return Extension if the rest doesn't have wildcards
|
||||
// e.g., *.rs is Extension, but *.test.* should be Glob
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
// Has wildcards -> use zlob for matching
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Has wildcards -> use config-specific glob detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
None
|
||||
@@ -162,8 +225,8 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
|
||||
parse_path_segment_trailing(token)
|
||||
}
|
||||
_ => {
|
||||
// Check for glob patterns using zlob's SIMD-optimized detection
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Check for glob patterns using config-specific detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
|
||||
@@ -232,6 +295,11 @@ 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 {
|
||||
@@ -239,12 +307,12 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
// Try extension first (*.rs) - simple patterns without additional wildcards
|
||||
if let Some(constraint) = parse_extension(token) {
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
// Has wildcards -> use zlob for matching
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Has wildcards -> use config-specific glob detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
None
|
||||
@@ -255,8 +323,8 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
parse_path_segment_trailing(token)
|
||||
}
|
||||
_ => {
|
||||
// Check for glob patterns using zlob's SIMD-optimized detection
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Check for glob patterns using config-specific detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
|
||||
@@ -297,11 +365,12 @@ fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
|
||||
}
|
||||
|
||||
/// Parse path segment with trailing slash: www/ -> PathSegment("www")
|
||||
/// Also supports multi-segment paths: libswscale/aarch64/ -> PathSegment("libswscale/aarch64")
|
||||
#[inline]
|
||||
fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
|
||||
if token.len() > 1 && token.ends_with('/') {
|
||||
let segment = token.trim_end_matches('/');
|
||||
if !segment.is_empty() && !segment.contains('/') {
|
||||
if !segment.is_empty() {
|
||||
Some(Constraint::PathSegment(segment))
|
||||
} else {
|
||||
None
|
||||
@@ -340,7 +409,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::FilePickerConfig;
|
||||
use crate::{FilePickerConfig, GrepConfig};
|
||||
|
||||
#[test]
|
||||
fn test_parse_extension() {
|
||||
@@ -384,8 +453,15 @@ mod tests {
|
||||
parse_path_segment_trailing("src/"),
|
||||
Some(Constraint::PathSegment("src"))
|
||||
);
|
||||
// Should not match paths with multiple segments
|
||||
assert_eq!(parse_path_segment_trailing("src/lib/"), None);
|
||||
// Multi-segment paths should work
|
||||
assert_eq!(
|
||||
parse_path_segment_trailing("src/lib/"),
|
||||
Some(Constraint::PathSegment("src/lib"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_path_segment_trailing("libswscale/aarch64/"),
|
||||
Some(Constraint::PathSegment("libswscale/aarch64"))
|
||||
);
|
||||
// Should not match without trailing slash
|
||||
assert_eq!(parse_path_segment_trailing("www"), None);
|
||||
}
|
||||
@@ -491,4 +567,569 @@ mod tests {
|
||||
_ => 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_text_preserves_backslash_escapes() {
|
||||
// Regex patterns like \w+ and \bfoo\b must survive grep_text()
|
||||
// The parser sees \w+ as a text token (not a constraint escape),
|
||||
// but strip_leading_backslash was stripping the \ anyway.
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("pub struct \\w+")
|
||||
.expect("should parse");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"pub struct \\w+",
|
||||
"Backslash-w in regex must be preserved"
|
||||
);
|
||||
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\bword\\b more")
|
||||
.expect("should parse");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"\\bword\\b more",
|
||||
"Backslash-b word boundaries must be preserved"
|
||||
);
|
||||
|
||||
// Single-token regex like "fn\\s+\\w+" returns None from parse()
|
||||
// (single token = no parsing needed, caller uses raw_query directly).
|
||||
let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Single-token regex should return None (no parsing)"
|
||||
);
|
||||
|
||||
// But the escaped constraint forms SHOULD still be stripped:
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\*.rs foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"*.rs foo",
|
||||
"Escaped constraint \\*.rs should still have backslash stripped"
|
||||
);
|
||||
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\/src/ foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"/src/ foo",
|
||||
"Escaped constraint \\/src/ should still have backslash stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI grep config tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_detects_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser
|
||||
.parse("libswscale/input.c rgba32ToY")
|
||||
.expect("Should parse");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("libswscale/input.c")
|
||||
),
|
||||
"Expected FilePath, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.grep_text(), "rgba32ToY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_detects_nested_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/main.rs fn main").expect("Should parse");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("src/main.rs")
|
||||
));
|
||||
assert_eq!(result.grep_text(), "fn main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_no_false_positive_trailing_slash() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/ pattern").expect("Should parse");
|
||||
// Should be PathSegment, NOT FilePath
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::PathSegment("src")),
|
||||
"Expected PathSegment, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_no_false_positive_no_slash() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("main.rs pattern").expect("Should parse");
|
||||
// No slash → not a file path, just text
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
assert_eq!(result.grep_text(), "main.rs pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_no_false_positive_no_extension() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/utils pattern").expect("Should parse");
|
||||
// No extension in last component → not a file path, just text
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
assert_eq!(result.grep_text(), "src/utils pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_wildcard_not_filepath() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/**/*.rs pattern").expect("Should parse");
|
||||
// Contains wildcards → should be a Glob, not FilePath
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
|
||||
"Expected Glob, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_star_text_star_is_glob() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("*quote* TODO").expect("Should parse");
|
||||
// `*quote*` should be recognised as a glob constraint in AI mode
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::Glob("*quote*")),
|
||||
"Expected Glob(*quote*), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_star_not_glob() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("* pattern").expect("Should parse");
|
||||
// Bare `*` should NOT be treated as a glob (too broad)
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"Expected no constraints, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_config_star_text_star_not_glob() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser.parse("*quote* TODO").expect("Should parse");
|
||||
// Regular grep mode should NOT treat `*quote*` as a glob
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"Expected no constraints in GrepConfig, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
|
||||
// ── File picker filename constraint tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_bare_filename_constraint() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("score.rs file_picker")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath("score.rs")),
|
||||
"Expected FilePath(\"score.rs\"), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_path_prefixed_filename_constraint() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("libswscale/slice.c lum_convert")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("libswscale/slice.c")
|
||||
),
|
||||
"Expected FilePath(\"libswscale/slice.c\"), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_single_token_filename_stays_fuzzy() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
// Single-token filename should NOT become a constraint — it should
|
||||
// return None so the caller uses the raw query for fuzzy matching.
|
||||
let result = parser.parse("score.rs");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Single-token filename should return None (fuzzy match), got {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_filename_with_multiple_fuzzy_parts() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("main.rs src components")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("main.rs")
|
||||
));
|
||||
assert_eq!(
|
||||
result.fuzzy_query,
|
||||
FuzzyQuery::Parts(smallvec::smallvec!["src", "components"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_version_number_not_filename() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("v2.0 release")
|
||||
.expect("Should parse multi-token query");
|
||||
// v2.0 extension starts with digit → not a filename constraint
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"v2.0 should not be a FilePath constraint, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_only_one_filepath_constraint() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("main.rs score.rs")
|
||||
.expect("Should parse multi-token query");
|
||||
// Only first filename becomes a constraint; second is text
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("main.rs")
|
||||
));
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_filename_with_extension_constraint() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("main.rs *.lua")
|
||||
.expect("Should parse multi-token query");
|
||||
// main.rs → FilePath, *.lua → Extension
|
||||
assert_eq!(result.constraints.len(), 2);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("main.rs")
|
||||
));
|
||||
assert!(matches!(
|
||||
result.constraints[1],
|
||||
Constraint::Extension("lua")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_dotfile_is_filename() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse(".gitignore src")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
|
||||
"Expected FilePath(\".gitignore\"), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_no_extension_not_filename() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("Makefile src")
|
||||
.expect("Should parse multi-token query");
|
||||
// No dot → not a filename constraint
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"Makefile should not be a FilePath constraint, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+202
-84
@@ -1,52 +1,68 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 12
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 12
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
- Features |fff.nvim-features|
|
||||
- Installation |fff.nvim-installation|
|
||||
FFF.nvimFinally a smart fuzzy file picker for neovim.
|
||||
- MCP |fff.nvim-mcp|
|
||||
- Neovim guide |fff.nvim-neovim-guide|
|
||||
1. Links |fff.nvim-links|
|
||||
FFFAI agents (MCP) | Neovim usersA fast file search for your AI and neovim, with memory built-in
|
||||
|
||||
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
|
||||
opinionated fuzzy file picker for neovim. Just for files, but 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 your AI agent and Neovim. Just for file
|
||||
search, but we do the file search really fff well.
|
||||
|
||||
It comes with a dedicated rust backend runtime that keep tracks of the file
|
||||
index, your file access and modifications, git status, and provides a
|
||||
comprehensive typo-resistant fuzzy search experience.
|
||||
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping
|
||||
with a strong focus on performance and useful search results. For humans -
|
||||
provides an unbelievable typo-resistant experience, for AI agents - implements
|
||||
the fastest file search with additional free memory suggesting the best search
|
||||
results based on various factors like frecency, git status, file size,
|
||||
definition matches, and more.
|
||||
|
||||
|
||||
FEATURES *fff.nvim-features*
|
||||
MCP *fff.nvim-mcp*
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- Typo resistant fuzzy search <https://github.com/saghen/frizbee>
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
FFF is an amazing way to reduce the time and tokens by giving your AI agent a
|
||||
bit of memory built-in to their file search tools. It makes your AI harness to
|
||||
find the code faster and spend less tokens by doing less roundtrips and reading
|
||||
less useless files.
|
||||
|
||||
You can install FFF as a dependency for your AI agent using a simple bash
|
||||
script:
|
||||
|
||||
>bash
|
||||
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
|
||||
<
|
||||
|
||||
|
||||
INSTALLATION *fff.nvim-installation*
|
||||
The installation script is here ./install-fff.sh <./install-fff.sh> if you want
|
||||
to review it before running.
|
||||
It will print out the instructions on how to connect it to your `Claude Code`,
|
||||
`Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to
|
||||
"use fff". Here is an example addition to `CLAUDE.md` that works perfectly:
|
||||
|
||||
>sh
|
||||
# CLAUDE.md
|
||||
For any file search or grep in the current git indexed directory use fff tools
|
||||
<
|
||||
|
||||
|
||||
[!NOTE] Although 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.
|
||||
NEOVIM GUIDE *fff.nvim-neovim-guide*
|
||||
|
||||
PREREQUISITES ~
|
||||
Here is some demo on the linux repository (100k files, 8GB) but you better fill
|
||||
it yourself and see the magic
|
||||
|
||||
FFF.nvim requires:
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- Rustup <https://rustup.rs/> (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
|
||||
|
||||
INSTALLATION ~
|
||||
|
||||
FFF.nvim requires neovim 0.10.0 or higher
|
||||
|
||||
|
||||
LAZY.NVIM
|
||||
|
||||
@@ -74,7 +90,26 @@ LAZY.NVIM
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
}
|
||||
},
|
||||
{
|
||||
"fg",
|
||||
function() require('fff').live_grep() end,
|
||||
desc = 'LiFFFe grep',
|
||||
},
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
<
|
||||
@@ -130,6 +165,10 @@ all available options:
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
@@ -145,6 +184,7 @@ all available options:
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -164,25 +204,29 @@ all available options:
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -207,6 +251,14 @@ all available options:
|
||||
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 = {
|
||||
@@ -217,24 +269,35 @@ all available options:
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
enabled = false, -- Show file info panel in preview
|
||||
show_scores = false, -- Show scores inline in the UI
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
}
|
||||
})
|
||||
},
|
||||
-- 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
|
||||
},
|
||||
})
|
||||
<
|
||||
|
||||
|
||||
@@ -244,20 +307,21 @@ KEY FEATURES ~
|
||||
AVAILABLE METHODS
|
||||
|
||||
>lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repository
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file list
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
<
|
||||
|
||||
just jump to the definition and see what other APIs are exposed we have a
|
||||
plenty
|
||||
|
||||
|
||||
COMMANDS
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
@@ -266,13 +330,6 @@ FFF.nvim provides several commands for interacting with the file picker:
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
|
||||
MULTILINE PASTE SUPPORT
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining
|
||||
all lines into a single search query. This is particularly useful when copying
|
||||
file paths from terminal output.
|
||||
|
||||
|
||||
DEBUG MODE
|
||||
|
||||
Toggle scoring information display:
|
||||
@@ -291,6 +348,97 @@ configurable):
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
|
||||
LIVE GREP SEARCH MODES
|
||||
|
||||
Live grep supports three search modes, cycled with `<S-Tab>`:
|
||||
|
||||
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
|
||||
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
|
||||
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
|
||||
|
||||
The current mode is shown on the right side of the input field (e.g., `plain`,
|
||||
`regex`, `fuzzy`) with color-coded highlighting.
|
||||
|
||||
You can customize which modes are available and their cycling order globally in
|
||||
your configuration, or per-call when invoking `live_grep()`.
|
||||
|
||||
**Global configuration:**
|
||||
|
||||
>lua
|
||||
require('fff').setup({
|
||||
grep = {
|
||||
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
**Per-call configuration:**
|
||||
|
||||
>lua
|
||||
-- Only fuzzy and plain modes for this specific grep
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Single mode (hides mode indicator completely)
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Pre-fill the search with an initial query
|
||||
require('fff').live_grep({ query = 'search term' })
|
||||
<
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and
|
||||
the cycle keybind does nothing.
|
||||
|
||||
|
||||
CONSTRAINTS
|
||||
|
||||
There are a number of constraints you can use to refine your search in both
|
||||
grep and file search mode:
|
||||
|
||||
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
|
||||
- `test/` - any deeply nested children of any test/ dir
|
||||
- `!something` - exclude results matching something
|
||||
- `!test/`, `!git:modified` - combining with any other constraint works as negation
|
||||
- `./**/*.{rs,lua}` - any valid glob expression via the fastest globbing library <https://github.com/dmtrKovalenko/zlob>
|
||||
|
||||
For grep only:
|
||||
|
||||
- `*.md`, `*.{c,h}` - extension filtering
|
||||
- `src/main.rs` - grep in a single file
|
||||
|
||||
In addition to that, all constraints can be combined together like:
|
||||
|
||||
>
|
||||
git:modified src/**/*.rs !src/**/mod.rs user controller
|
||||
<
|
||||
|
||||
This will find all the files that qualify the constraints and:
|
||||
|
||||
- match **both** user and controller (for file mode)
|
||||
- match "user controller" (for grep mode)
|
||||
|
||||
|
||||
CROSS-MODE SUGGESTIONS
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search
|
||||
mode and displays the results as suggestions:
|
||||
|
||||
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
|
||||
- **Grep search with no matches** → shows suggested **file name matches** for the same query
|
||||
|
||||
Suggestions are clearly labeled with a "No results found. Suggested …" banner
|
||||
(highlighted with `hl.suggestion_header`). You can navigate and select
|
||||
suggestion items just like normal results — selecting a grep suggestion will
|
||||
open the file at the matching line.
|
||||
|
||||
|
||||
GIT STATUS HIGHLIGHTING
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators
|
||||
@@ -386,47 +534,17 @@ VIEWING LOGS
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
>vim
|
||||
>
|
||||
:FFFOpenLog
|
||||
<
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
|
||||
location).
|
||||
|
||||
==============================================================================
|
||||
1. Links *fff.nvim-links*
|
||||
|
||||
COMMON ISSUES
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure 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. *Chart showing the superiority of fff.nvim over builtin claude code tools*: ./chart.png
|
||||
|
||||
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
|
||||
|
||||
|
||||
@@ -35,9 +35,13 @@
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./crates/fff-nvim/Cargo.toml);
|
||||
|
||||
# Common arguments can be set here to avoid repeating them later
|
||||
# Note: changes here will rebuild all dependency crates
|
||||
commonArgs = {
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
strictDeps = true;
|
||||
|
||||
@@ -53,7 +57,7 @@
|
||||
commonArgs
|
||||
// {
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
doCheck = false;
|
||||
}
|
||||
);
|
||||
# Copies the dynamic library into the target/release folder
|
||||
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
# FFF MCP Server installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
|
||||
|
||||
REPO="dmtrKovalenko/fff.nvim"
|
||||
BINARY_NAME="fff-mcp"
|
||||
INSTALL_DIR="${FFF_MCP_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
info() { printf '\033[1;34m%s\033[0m\n' "$*"; }
|
||||
success() { printf '\033[1;38;5;208m%s\033[0m\n' "$*"; }
|
||||
warn() { printf '\033[1;33m%s\033[0m\n' "$*"; }
|
||||
error() { printf '\033[1;31mError: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Print JSON with syntax highlighting via jq if available, plain otherwise
|
||||
print_json() {
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "$1" | jq .
|
||||
else
|
||||
echo "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
local os arch target
|
||||
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
case "$os" in
|
||||
Linux)
|
||||
# Prefer musl (static) for maximum compatibility
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-unknown-linux-musl" ;;
|
||||
aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
Darwin)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-apple-darwin" ;;
|
||||
aarch64|arm64) target="aarch64-apple-darwin" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-pc-windows-msvc" ;;
|
||||
aarch64|arm64) target="aarch64-pc-windows-msvc" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
*) error "Unsupported OS: $os" ;;
|
||||
esac
|
||||
|
||||
echo "$target"
|
||||
}
|
||||
|
||||
get_latest_release_tag() {
|
||||
local target="$1"
|
||||
local releases_json
|
||||
releases_json=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases") \
|
||||
|| error "Failed to fetch releases from https://github.com/${REPO}/releases"
|
||||
|
||||
# Find the first release that contains an fff-mcp binary for our platform
|
||||
local tag
|
||||
tag=$(echo "$releases_json" \
|
||||
| grep -oE '"(tag_name|name)": *"[^"]*"' \
|
||||
| awk -v target="fff-mcp-${target}" '
|
||||
/"tag_name":/ { gsub(/.*": *"|"/, ""); current_tag = $0; next }
|
||||
/"name":/ && index($0, target) { print current_tag; exit }
|
||||
')
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
error "No release found containing fff-mcp binaries for ${target}. The MCP build may not have been released yet."
|
||||
fi
|
||||
echo "$tag"
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local target="$1"
|
||||
local tag="$2"
|
||||
local ext=""
|
||||
|
||||
case "$target" in
|
||||
*windows*) ext=".exe" ;;
|
||||
esac
|
||||
|
||||
local filename="${BINARY_NAME}-${target}${ext}"
|
||||
local url="https://github.com/${REPO}/releases/download/${tag}/${filename}"
|
||||
local checksum_url="${url}.sha256"
|
||||
|
||||
info "Downloading ${filename} from release ${tag}..."
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url" 2>/dev/null; then
|
||||
echo "" >&2
|
||||
printf '\033[1;31mError: Failed to download binary for your platform.\033[0m\n' >&2
|
||||
echo "" >&2
|
||||
echo " URL: ${url}" >&2
|
||||
echo " Release: ${tag}" >&2
|
||||
echo " Platform: ${target}" >&2
|
||||
echo "" >&2
|
||||
echo "This likely means the MCP binary hasn't been built for this release yet." >&2
|
||||
echo "Check available releases at: https://github.com/${REPO}/releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify checksum if sha256sum is available
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
if curl -fsSL -o "${tmp_dir}/${filename}.sha256" "$checksum_url" 2>/dev/null; then
|
||||
info "Verifying checksum..."
|
||||
(cd "$tmp_dir" && sha256sum -c "${filename}.sha256") \
|
||||
|| error "Checksum verification failed!"
|
||||
else
|
||||
warn "Checksum file not available, skipping verification."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
|
||||
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
}
|
||||
|
||||
check_path() {
|
||||
case ":$PATH:" in
|
||||
*":${INSTALL_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
|
||||
warn "${INSTALL_DIR} is not in your PATH."
|
||||
echo ""
|
||||
echo "Add it to your shell profile:"
|
||||
echo ""
|
||||
|
||||
local shell_name
|
||||
shell_name="$(basename "${SHELL:-bash}")"
|
||||
case "$shell_name" in
|
||||
zsh)
|
||||
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc"
|
||||
echo " source ~/.zshrc"
|
||||
;;
|
||||
fish)
|
||||
echo " fish_add_path ${INSTALL_DIR}"
|
||||
;;
|
||||
*)
|
||||
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc"
|
||||
echo " source ~/.bashrc"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_setup_instructions() {
|
||||
local binary_path="${INSTALL_DIR}/${BINARY_NAME}"
|
||||
local found_any=false
|
||||
|
||||
echo ""
|
||||
success "FFF MCP Server installed successfully!"
|
||||
echo ""
|
||||
info "Setup with your AI coding assistant:"
|
||||
echo ""
|
||||
|
||||
# Claude Code
|
||||
if command -v claude &>/dev/null; then
|
||||
found_any=true
|
||||
success "[Claude Code] detected"
|
||||
echo ""
|
||||
echo "Global (recommended):"
|
||||
echo "claude mcp add -s user fff -- ${binary_path}"
|
||||
echo ""
|
||||
echo "Or project-level .mcp.json (uses PATH):"
|
||||
echo ""
|
||||
print_json '{
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# OpenCode
|
||||
if command -v opencode &>/dev/null; then
|
||||
found_any=true
|
||||
success "[OpenCode] detected"
|
||||
echo ""
|
||||
echo "Add to ~/.config/opencode/opencode.json:"
|
||||
echo ""
|
||||
print_json '{
|
||||
"mcp": {
|
||||
"fff": {
|
||||
"type": "local",
|
||||
"command": ["fff-mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Codex
|
||||
if command -v codex &>/dev/null; then
|
||||
found_any=true
|
||||
success "[Codex] detected"
|
||||
echo ""
|
||||
echo "codex mcp add fff -- fff-mcp"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$found_any" = false ]; then
|
||||
echo "No AI coding assistants detected."
|
||||
echo ""
|
||||
echo "Binary path: ${binary_path}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Binary: ${binary_path}"
|
||||
echo "Docs: https://github.com/${REPO}"
|
||||
echo ""
|
||||
info "Tip: Add this to your CLAUDE.md or AGENTS.md to make AI use fff for all searches:"
|
||||
echo "\""
|
||||
echo "Use the fff MCP tools for all file search operations instead of default tools."
|
||||
echo "\""
|
||||
|
||||
|
||||
}
|
||||
|
||||
main() {
|
||||
info "Installing FFF MCP Server..."
|
||||
echo ""
|
||||
|
||||
local target
|
||||
target="$(detect_platform)"
|
||||
info "Detected platform: ${target}"
|
||||
|
||||
local tag
|
||||
tag="$(get_latest_release_tag "$target")"
|
||||
|
||||
download_binary "$target" "$tag"
|
||||
check_path
|
||||
print_setup_instructions
|
||||
}
|
||||
|
||||
main
|
||||
+11
-10
@@ -41,7 +41,7 @@ local function detect_combo_item(items, file_picker, combo_boost_score_multiplie
|
||||
end
|
||||
|
||||
local function create_header_text(combo_count, win_width, disable_combo_display)
|
||||
local combo_text = nil
|
||||
local combo_text
|
||||
if disable_combo_display then
|
||||
combo_text = LAST_MATCH_TEXT_FORMAT
|
||||
else
|
||||
@@ -57,32 +57,32 @@ end
|
||||
|
||||
local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl)
|
||||
local config = require('fff.conf').get()
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, { end_row = line_idx, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
config.hl.combo_header,
|
||||
line_idx - 1,
|
||||
LEFT_HEADER_PADDING,
|
||||
LEFT_HEADER_PADDING + text_len
|
||||
{ end_col = LEFT_HEADER_PADDING + text_len, hl_group = config.hl.combo_header }
|
||||
)
|
||||
end
|
||||
|
||||
local function get_or_create_overlay_buf(state_key)
|
||||
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = overlay_state[state_key] })
|
||||
end
|
||||
return overlay_state[state_key]
|
||||
end
|
||||
|
||||
local function update_overlay_content(buf, content, border_hl)
|
||||
-- Batch all buffer operations together for performance
|
||||
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
|
||||
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1)
|
||||
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_extmark(buf, overlay_state.ns_id, 0, 0, { end_row = 1, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
|
||||
end
|
||||
|
||||
local function position_overlay_window(state_key, buf, width, row, col)
|
||||
@@ -101,10 +101,11 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
if overlay_state[state_key] and vim.api.nvim_win_is_valid(overlay_state[state_key]) then
|
||||
vim.api.nvim_win_set_config(overlay_state[state_key], win_config)
|
||||
else
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
|
||||
end
|
||||
|
||||
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
|
||||
vim.api.nvim_set_option_value('winhighlight', 'Normal:Normal', { win = overlay_state[state_key] })
|
||||
end
|
||||
|
||||
local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
|
||||
+137
-12
@@ -1,8 +1,82 @@
|
||||
local M = {}
|
||||
|
||||
--@class fff.conf.State
|
||||
--- @class FffLayoutConfig
|
||||
--- @field height number
|
||||
--- @field width number
|
||||
--- @field prompt_position string
|
||||
--- @field preview_position string
|
||||
--- @field preview_size number
|
||||
--- @field show_scrollbar boolean
|
||||
--- @field path_shorten_strategy string
|
||||
|
||||
--- @class FffPreviewConfig
|
||||
--- @field enabled boolean
|
||||
--- @field max_size number
|
||||
--- @field chunk_size number
|
||||
--- @field binary_file_threshold number
|
||||
--- @field imagemagick_info_format_str string
|
||||
--- @field line_numbers boolean
|
||||
--- @field cursorlineopt string
|
||||
--- @field wrap_lines boolean
|
||||
--- @field filetypes table<string, table>
|
||||
|
||||
--- @class FffKeymapsConfig
|
||||
--- @field close string
|
||||
--- @field select string
|
||||
--- @field select_split string
|
||||
--- @field select_vsplit string
|
||||
--- @field select_tab string
|
||||
--- @field move_up string|string[]
|
||||
--- @field move_down string|string[]
|
||||
--- @field preview_scroll_up string
|
||||
--- @field preview_scroll_down string
|
||||
--- @field toggle_debug string
|
||||
--- @field cycle_grep_modes string
|
||||
--- @field cycle_previous_query string
|
||||
--- @field toggle_select string
|
||||
--- @field send_to_quickfix string
|
||||
--- @field focus_list string
|
||||
--- @field focus_preview string
|
||||
|
||||
--- @class FffFrecencyConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
|
||||
--- @class FffHistoryConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
--- @field min_combo_count number
|
||||
--- @field combo_boost_score_multiplier number
|
||||
|
||||
--- @class FffGrepConfig
|
||||
--- @field max_file_size number
|
||||
--- @field max_matches_per_file number
|
||||
--- @field smart_case boolean
|
||||
--- @field time_budget_ms number
|
||||
--- @field modes string[]
|
||||
|
||||
--- @class FffConfig
|
||||
--- @field base_path string
|
||||
--- @field prompt string
|
||||
--- @field title string
|
||||
--- @field max_results number
|
||||
--- @field max_threads number
|
||||
--- @field lazy_sync boolean
|
||||
--- @field layout FffLayoutConfig
|
||||
--- @field preview FffPreviewConfig
|
||||
--- @field keymaps FffKeymapsConfig
|
||||
--- @field hl table<string, string>
|
||||
--- @field frecency FffFrecencyConfig
|
||||
--- @field history FffHistoryConfig
|
||||
--- @field git table
|
||||
--- @field debug table
|
||||
--- @field logging table
|
||||
--- @field file_picker table
|
||||
--- @field grep FffGrepConfig
|
||||
|
||||
---@class fff.conf.State
|
||||
local state = {
|
||||
---@type table | nil
|
||||
---@type FffConfig|nil
|
||||
config = nil,
|
||||
}
|
||||
|
||||
@@ -99,6 +173,19 @@ local function handle_deprecated_config(user_config)
|
||||
return migrated_config
|
||||
end
|
||||
|
||||
---@param name table list of highlight groups to choose from
|
||||
---@return string one of the provided groups
|
||||
local function fallback_hl(name)
|
||||
local resolved_hl
|
||||
for _, hl in ipairs(name) do
|
||||
local resolved_group = vim.api.nvim_get_hl(0, { name = hl })
|
||||
|
||||
if not vim.tbl_isempty(resolved_group) then resolved_hl = hl end
|
||||
end
|
||||
|
||||
return resolved_hl or name[#name]
|
||||
end
|
||||
|
||||
local function init()
|
||||
local config = vim.g.fff or {}
|
||||
local default_config = {
|
||||
@@ -107,14 +194,24 @@ local function init()
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to nil to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
path_shorten_strategy = 'middle_number', -- or 'middle', 'end'
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -123,6 +220,7 @@ local function init()
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both',
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -136,30 +234,35 @@ local function init()
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -184,30 +287,52 @@ local function init()
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
enabled = false, -- Show file info panel in preview
|
||||
show_scores = false, -- Show scores inline in the UI
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 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)
|
||||
@@ -217,10 +342,10 @@ local function init()
|
||||
end
|
||||
|
||||
--- Setup the file picker with the given configuration
|
||||
--- @param config table Configuration options
|
||||
--- @param config FffConfig Configuration options
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- @return table the fff configuration
|
||||
--- @return FffConfig the fff configuration
|
||||
function M.get()
|
||||
if not state.config then init() end
|
||||
return state.config
|
||||
@@ -230,7 +355,7 @@ end
|
||||
function M.toggle_debug()
|
||||
local old_debug_state = state.config.debug.show_scores
|
||||
state.config.debug.show_scores = not state.config.debug.show_scores
|
||||
state.config.debug.show_file_info = state.config.debug.show_scores
|
||||
state.config.debug.enabled = state.config.debug.show_scores
|
||||
local status = state.config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
return old_debug_state ~= state.config.debug.show_scores
|
||||
|
||||
+1
-6
@@ -46,6 +46,7 @@ local function setup_global_autocmds(config)
|
||||
vim.api.nvim_create_autocmd('DirChanged', {
|
||||
group = group,
|
||||
callback = function()
|
||||
if vim.v.event.scope == 'window' then return end
|
||||
local new_cwd = vim.v.event.cwd
|
||||
if state.initialized and new_cwd and new_cwd ~= config.base_path then
|
||||
vim.schedule(function()
|
||||
@@ -64,12 +65,6 @@ local function setup_global_autocmds(config)
|
||||
end,
|
||||
desc = 'Automatically sync FFF directory changes',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('VimLeavePre', {
|
||||
group = group,
|
||||
callback = function() pcall(fuzzy.cleanup_file_picker) end,
|
||||
desc = 'Cleanup FFF background threads on Neovim exit',
|
||||
})
|
||||
end
|
||||
|
||||
--- @return boolean
|
||||
|
||||
+97
-14
@@ -25,7 +25,26 @@ end
|
||||
local function binary_exists(plugin_dir)
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
local stat = vim.uv.fs_stat(binary_path)
|
||||
return stat and stat.type == 'file'
|
||||
if stat and stat.type == 'file' then return true end
|
||||
|
||||
-- On Windows the rename over a loaded DLL fails, so a verified binary may be
|
||||
-- left at binary_path .. '.tmp'. Promote it now that the old session is gone.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
local tmp_stat = vim.uv.fs_stat(tmp_path)
|
||||
if tmp_stat and tmp_stat.type == 'file' then
|
||||
-- Verify the .tmp is a valid library before promoting it, in case the
|
||||
-- process was killed between the loadlib check and the rename attempt
|
||||
-- during a previous download, leaving a corrupt or partial .tmp on disk.
|
||||
local loader = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
return false
|
||||
end
|
||||
local ok = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
return ok ~= nil
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function download_file(url, output_path, opts, callback)
|
||||
@@ -53,6 +72,12 @@ local function download_file(url, output_path, opts, callback)
|
||||
table.insert(curl_args, opts.proxy)
|
||||
end
|
||||
|
||||
if opts.extra_curl_args then
|
||||
for _, arg in ipairs(opts.extra_curl_args) do
|
||||
table.insert(curl_args, arg)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(curl_args, url)
|
||||
vim.system(curl_args, {}, function(result)
|
||||
if result.code ~= 0 then
|
||||
@@ -71,31 +96,66 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
local extension = system.get_lib_extension()
|
||||
local binary_name = triple .. '.' .. extension
|
||||
local url = string.format('https://github.com/%s/releases/download/%s/%s', GITHUB_REPO, version, binary_name)
|
||||
|
||||
vim.schedule(function()
|
||||
vim.notify(string.format('Downloading fff.nvim binary for ' .. version), vim.log.levels.INFO)
|
||||
vim.notify(string.format('Do not open fff until you see a success notification.'), vim.log.levels.WARN)
|
||||
end)
|
||||
|
||||
download_file(url, binary_path, {
|
||||
-- Download to a temp path first so we can validate before replacing the live binary.
|
||||
-- If we wrote directly to binary_path and the current process already has the old
|
||||
-- library loaded, package.loadlib() on the same path returns the *cached* handle —
|
||||
-- meaning a truncated download would pass validation silently.
|
||||
-- Using a distinct temp path forces dlopen to load the new file for real.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
|
||||
download_file(url, tmp_path, {
|
||||
proxy = opts.proxy,
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
vim.uv.fanoushkas_unlink(tmp_path)
|
||||
callback(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Verify the binary can be loaded
|
||||
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
|
||||
vim.schedule(function()
|
||||
-- Validate the downloaded binary by actually loading it (temp path is not yet
|
||||
-- loaded by this process, so dlopen loads the new file for real and catches
|
||||
-- truncated or corrupt downloads).
|
||||
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing.
|
||||
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
|
||||
if not ok then
|
||||
vim.uv.fs_unlink(binary_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
|
||||
return
|
||||
end
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function() vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO) end)
|
||||
callback(true, nil)
|
||||
-- Atomically replace the live binary only after successful validation.
|
||||
-- On Windows the old .dll may be locked by the current process, so rename can
|
||||
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
|
||||
-- so the next Neovim start can pick it up automatically.
|
||||
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
if not rename_ok then
|
||||
if vim.uv.os_uname().sysname:lower():match('windows') then
|
||||
vim.notify(
|
||||
'fff.nvim binary downloaded to '
|
||||
.. tmp_path
|
||||
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
callback(true, nil)
|
||||
else
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -131,7 +191,11 @@ function M.download_binary(callback)
|
||||
if callback then
|
||||
callback(false, err)
|
||||
else
|
||||
error('Failed to download fff.nvim binary: ' .. (err or 'unknown error'))
|
||||
vim.schedule(
|
||||
function()
|
||||
vim.notify('Failed to download fff.nvim binary: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
|
||||
end
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -160,8 +224,14 @@ function M.build_binary(callback)
|
||||
end
|
||||
|
||||
function M.download_or_build_binary()
|
||||
local done = false
|
||||
local fatal_error = nil
|
||||
|
||||
M.ensure_downloaded({ force = true }, function(download_success, download_error)
|
||||
if download_success then return end
|
||||
if download_success then
|
||||
done = true
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(
|
||||
function()
|
||||
@@ -174,12 +244,25 @@ function M.download_or_build_binary()
|
||||
|
||||
M.build_binary(function(build_success, build_error)
|
||||
if not build_success then
|
||||
error('Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error'))
|
||||
fatal_error = 'Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error')
|
||||
else
|
||||
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
|
||||
end
|
||||
done = true
|
||||
end)
|
||||
end)
|
||||
|
||||
-- Block the caller (and keep the Neovim event loop alive) until the entire
|
||||
-- download-or-build chain finishes. This is critical for lazy.nvim build
|
||||
-- hooks: lazy returns from the hook immediately after this function returns,
|
||||
-- and if Neovim exits before the final rename(tmp → libfff_nvim.{dylib,so,dll})
|
||||
-- executes, the binary is never written to disk. vim.wait pumps the event
|
||||
-- loop so all vim.system / vim.schedule callbacks can fire.
|
||||
local timeout_ms = 1000 * 60 * 2 -- 2 minutes
|
||||
local ok, wait_err = vim.wait(timeout_ms, function() return done end, 100)
|
||||
if not ok and wait_err == -2 then error('fff.nvim: download_or_build_binary timed out') end
|
||||
|
||||
if fatal_error then error(fatal_error) end
|
||||
end
|
||||
|
||||
function M.get_binary_path()
|
||||
|
||||
@@ -44,7 +44,7 @@ function M.get_directory_icon(dirname)
|
||||
end
|
||||
elseif M.provider_name == 'mini.icons' then
|
||||
if M.provider.get then
|
||||
local icon, hl, is_default = M.provider.get('directory', basename)
|
||||
local icon, hl, _ = M.provider.get('directory', basename)
|
||||
if icon and icon ~= '' and hl then return icon, hl end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,11 +25,11 @@ local function reserve_image_buffer_space(bufnr, metadata_lines_count)
|
||||
table.insert(buffer_lines, '')
|
||||
end
|
||||
|
||||
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
|
||||
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
|
||||
|
||||
return metadata_lines_count or 2
|
||||
end
|
||||
@@ -44,9 +44,9 @@ local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
|
||||
metadata_lines[i] = info_lines[i] or ''
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function identify_image_lines_async(file_path, bufnr, callback)
|
||||
@@ -144,13 +144,12 @@ end
|
||||
--- Fully asynchronous
|
||||
--- @param file_path string Path to the image file
|
||||
--- @param bufnr number Buffer number to display in
|
||||
--- @param max_width number Maximum width in characters
|
||||
--- @param max_height number Maximum height in characters
|
||||
--- @return boolean
|
||||
function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
max_width = max_width or 80
|
||||
max_height = max_height or 24
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', false)
|
||||
function M.display_image(file_path, bufnr)
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
for _, win in ipairs(wins) do
|
||||
vim.api.nvim_set_option_value('number', false, { win = win })
|
||||
end
|
||||
|
||||
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
|
||||
local image_content_starts_at_line = reserved_metadata_lines + 1
|
||||
@@ -167,9 +166,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'',
|
||||
'snacks.nvim plugin is not installed or not available.',
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -180,9 +179,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'Terminal does not support image preview.',
|
||||
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -193,9 +192,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'File format is not supported for image preview.',
|
||||
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ end
|
||||
--- @param max_threads number|nil Maximum number of threads to use
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
|
||||
--- @param page_size number Items per page
|
||||
--- @param page_size number|nil Items per page (nil uses config default)
|
||||
--- @return table List of matching files
|
||||
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
|
||||
local config = require('fff.conf').get()
|
||||
|
||||
+234
-285
@@ -5,48 +5,22 @@ local location_utils = require('fff.location_utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Additional fallback for certain ambiguous filetypes which vim.filetype.match is not handling correctly
|
||||
local function get_fixed_filetype_detection(extension)
|
||||
local extension_map = {
|
||||
ts = 'typescript',
|
||||
tex = 'latex',
|
||||
md = 'markdown',
|
||||
txt = 'text',
|
||||
}
|
||||
|
||||
return extension_map[extension]
|
||||
end
|
||||
|
||||
local function detect_filetype(file_path)
|
||||
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
|
||||
if has_plenary then
|
||||
local detected = plenary_filetype.detect(file_path)
|
||||
if detected and detected ~= '' then return detected end
|
||||
end
|
||||
|
||||
local builtin_filetype = vim.filetype.match({ filename = file_path })
|
||||
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
|
||||
|
||||
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
return get_fixed_filetype_detection(extension)
|
||||
end
|
||||
|
||||
local function set_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function append_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
if not lines or #lines == 0 then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
local current_lines = vim.api.nvim_buf_line_count(bufnr)
|
||||
vim.api.nvim_buf_set_lines(bufnr, current_lines, current_lines, false, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function find_existing_buffer(file_path)
|
||||
@@ -71,6 +45,32 @@ local function cleanup_file_operation()
|
||||
end
|
||||
end
|
||||
|
||||
--- Process raw chunk data into complete lines, joining any leftover bytes
|
||||
--- from the previous chunk and storing any trailing partial line for the next.
|
||||
--- @param data string Raw chunk data
|
||||
--- @return string[] Complete lines (may be empty if the entire chunk is a partial line)
|
||||
local function split_chunk_with_remainder(data)
|
||||
if not data or data == '' then return {} end
|
||||
|
||||
local fo = M.state.file_operation
|
||||
local prefix = fo and fo.remainder or ''
|
||||
local combined = prefix .. data
|
||||
|
||||
local lines = vim.split(combined, '\n', { plain = true })
|
||||
|
||||
if combined:sub(-1) ~= '\n' then
|
||||
-- Data doesn't end on a line boundary: last element is a partial line
|
||||
local partial = table.remove(lines) or ''
|
||||
if fo then fo.remainder = partial end
|
||||
else
|
||||
-- Data ends on a line boundary: remove the trailing empty element
|
||||
if #lines > 0 and lines[#lines] == '' then table.remove(lines) end
|
||||
if fo then fo.remainder = '' end
|
||||
end
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
local function init_dynamic_loading_async(file_path, callback)
|
||||
cleanup_file_operation()
|
||||
|
||||
@@ -79,7 +79,15 @@ local function init_dynamic_loading_async(file_path, callback)
|
||||
M.state.has_more_content = true
|
||||
M.state.is_loading = false
|
||||
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
|
||||
-- Stale callback: preview moved on to a different file
|
||||
if M.state.preview_generation ~= generation then
|
||||
if fd then pcall(vim.uv.fs_close, fd) end
|
||||
return
|
||||
end
|
||||
|
||||
if err or not fd then
|
||||
callback(false, 'Failed to open file: ' .. (err or 'unknown error'))
|
||||
return
|
||||
@@ -89,6 +97,7 @@ local function init_dynamic_loading_async(file_path, callback)
|
||||
fd = fd,
|
||||
file_path = file_path,
|
||||
position = 0,
|
||||
remainder = '',
|
||||
}
|
||||
|
||||
callback(true)
|
||||
@@ -103,9 +112,13 @@ local function load_forward_chunk_async(target_size, callback)
|
||||
|
||||
M.state.is_loading = true
|
||||
local chunk_size = target_size or (M.config.chunk_size or 16384)
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
vim.uv.fs_read(M.state.file_operation.fd, chunk_size, M.state.file_operation.position, function(err, data)
|
||||
vim.schedule(function()
|
||||
-- Stale callback: a newer preview has started, discard this result
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
|
||||
M.state.is_loading = false
|
||||
|
||||
if err then
|
||||
@@ -115,8 +128,14 @@ local function load_forward_chunk_async(target_size, callback)
|
||||
|
||||
if not data or #data == 0 then
|
||||
M.state.has_more_content = false
|
||||
-- Flush any remaining partial line as the final piece of data
|
||||
local final_remainder = M.state.file_operation and M.state.file_operation.remainder or ''
|
||||
cleanup_file_operation()
|
||||
callback('', nil)
|
||||
if final_remainder ~= '' then
|
||||
callback(final_remainder .. '\n', nil)
|
||||
else
|
||||
callback('', nil)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -135,8 +154,15 @@ local function load_next_chunk_async(chunk_size, callback)
|
||||
load_forward_chunk_async(chunk_size, callback)
|
||||
end
|
||||
|
||||
local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
|
||||
local ensure_content_loaded_async
|
||||
|
||||
local function read_file_streaming_async(file_path, callback)
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
init_dynamic_loading_async(file_path, function(success, error_msg)
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
|
||||
if not success then
|
||||
callback(nil, error_msg)
|
||||
return
|
||||
@@ -159,22 +185,26 @@ local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
end
|
||||
|
||||
load_next_chunk_async(initial_chunk_size, function(data, err)
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
|
||||
if data and data ~= '' then
|
||||
-- there seems to be no other way to append the buffer other than the lines :(
|
||||
local lines = vim.split(data, '\n', { plain = true })
|
||||
local lines = split_chunk_with_remainder(data)
|
||||
M.state.loaded_lines = #lines
|
||||
M.state.content_height = #lines
|
||||
|
||||
-- If we have a location and didn't load enough lines, try to load more
|
||||
local loading_more = false
|
||||
if M.state.location then
|
||||
local target_line = location_utils.get_target_line(M.state.location)
|
||||
if target_line and #lines < target_line and M.state.has_more_content then
|
||||
-- Schedule additional loading after the initial callback
|
||||
vim.schedule(function() ensure_content_loaded_async(target_line) end)
|
||||
loading_more = true
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
callback(lines, err)
|
||||
callback(lines, err, loading_more)
|
||||
else
|
||||
callback(nil, err)
|
||||
end
|
||||
@@ -182,55 +212,58 @@ local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
end)
|
||||
end
|
||||
|
||||
local function ensure_content_loaded_async(target_line)
|
||||
ensure_content_loaded_async = function(target_line)
|
||||
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
||||
if not M.state.has_more_content or M.state.is_loading then return end
|
||||
-- Guard against missing file handle: without it load_next_chunk_async returns
|
||||
-- synchronously with empty data, which triggers apply_location_highlighting
|
||||
-- -> ensure_content_loaded_async again, causing infinite recursion (stack overflow).
|
||||
if not M.state.file_operation then
|
||||
M.state.has_more_content = false
|
||||
return
|
||||
end
|
||||
|
||||
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
local buffer_needed = target_line + 50
|
||||
|
||||
if current_buffer_lines >= buffer_needed then return end
|
||||
|
||||
if current_buffer_lines < buffer_needed then
|
||||
local loading_line = string.format('Loading more content... (%d lines loaded)', M.state.loaded_lines)
|
||||
append_buffer_lines(M.state.bufnr, { '', loading_line })
|
||||
end
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
load_next_chunk_async(M.config.chunk_size, function(data, err)
|
||||
if err then
|
||||
vim.notify('Error loading file content: ' .. err, vim.log.levels.ERROR)
|
||||
-- Remove loading message on error
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
set_buffer_lines(M.state.bufnr, existing_lines)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- Use a larger chunk to reach the target faster instead of many small 8KB reads
|
||||
local lines_needed = buffer_needed - current_buffer_lines
|
||||
local estimated_bytes = math.max(M.config.chunk_size, lines_needed * 120)
|
||||
|
||||
load_next_chunk_async(estimated_bytes, function(data, err)
|
||||
-- Stale callback: preview moved on to a different file
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
||||
|
||||
if err then return end
|
||||
|
||||
if data and data ~= '' then
|
||||
local chunk_lines = vim.split(data, '\n', { plain = true })
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
local new_content = vim.list_extend(existing_lines, chunk_lines)
|
||||
set_buffer_lines(M.state.bufnr, new_content)
|
||||
else
|
||||
append_buffer_lines(M.state.bufnr, chunk_lines)
|
||||
end
|
||||
local chunk_lines = split_chunk_with_remainder(data)
|
||||
if #chunk_lines > 0 then append_buffer_lines(M.state.bufnr, chunk_lines) end
|
||||
|
||||
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
M.state.loaded_lines = M.state.content_height
|
||||
else
|
||||
-- No more data available - remove the loading message
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
set_buffer_lines(M.state.bufnr, existing_lines)
|
||||
M.state.content_height = #existing_lines
|
||||
M.state.loaded_lines = M.state.content_height
|
||||
|
||||
-- If we still haven't loaded enough, schedule another chunk
|
||||
if M.state.loaded_lines < buffer_needed and M.state.has_more_content then
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
|
||||
end)
|
||||
else
|
||||
-- Enough content loaded — re-apply location highlighting so the
|
||||
-- preview scrolls to the correct line now that it exists in the buffer
|
||||
M.apply_location_highlighting(M.state.bufnr)
|
||||
end
|
||||
else
|
||||
-- EOF with no additional data — mark loading as finished to prevent
|
||||
-- apply_location_highlighting -> ensure_content_loaded_async recursion,
|
||||
-- then apply highlighting with whatever content we have.
|
||||
M.state.has_more_content = false
|
||||
M.apply_location_highlighting(M.state.bufnr)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -239,8 +272,8 @@ local function link_buffer_content(source_bufnr, target_bufnr)
|
||||
local lines = vim.api.nvim_buf_get_lines(source_bufnr, 0, -1, false)
|
||||
set_buffer_lines(target_bufnr, lines)
|
||||
|
||||
local source_ft = vim.api.nvim_buf_get_option(source_bufnr, 'filetype')
|
||||
if source_ft ~= '' then vim.api.nvim_buf_set_option(target_bufnr, 'filetype', source_ft) end
|
||||
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
|
||||
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
|
||||
|
||||
M.state.has_more_content = false
|
||||
M.state.total_file_lines = #lines
|
||||
@@ -263,10 +296,11 @@ M.state = {
|
||||
loading_chunk_size = 1000,
|
||||
is_loading = false,
|
||||
has_more_content = true,
|
||||
file_handle = nil,
|
||||
file_handle = nil, ---@type uv.uv_fs_t|nil
|
||||
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
|
||||
location = nil, -- Current location data for highlighting
|
||||
location_namespace = nil, -- Namespace for location highlighting
|
||||
preview_generation = 0, -- Monotonically increasing token to detect stale async callbacks
|
||||
}
|
||||
|
||||
--- Setup preview configuration
|
||||
@@ -281,9 +315,8 @@ end
|
||||
|
||||
--- Check if file is too big for initial preview (inspired by snacks.nvim)
|
||||
--- @param file_path string Path to the file
|
||||
--- @param bufnr number|nil Buffer number to check (unused with dynamic loading)
|
||||
--- @return boolean True if file is too big for initial preview
|
||||
function M.is_big_file(file_path, bufnr)
|
||||
function M.is_big_file(file_path)
|
||||
-- Only check file size for early detection - no line limits with dynamic loading
|
||||
local stat = vim.uv.fs_stat(file_path)
|
||||
if stat and stat.size > M.config.max_size then return true end
|
||||
@@ -291,166 +324,6 @@ function M.is_big_file(file_path, bufnr)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if file is binary (async version)
|
||||
--- @param file_path string Path to the file
|
||||
--- @param callback function Callback with (is_binary: boolean)
|
||||
function M.is_binary_file_async(file_path, callback)
|
||||
local ext = vim.fn.fnamemodify(file_path, ':e')
|
||||
local binary_extensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
'webp',
|
||||
'ico',
|
||||
'pdf',
|
||||
'ps',
|
||||
'eps',
|
||||
'heic',
|
||||
'avif',
|
||||
-- Archives
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
-- Executables
|
||||
'exe',
|
||||
'dll',
|
||||
'so',
|
||||
'dylib',
|
||||
'bin',
|
||||
-- Audio/Video
|
||||
'mp3',
|
||||
'mp4',
|
||||
'avi',
|
||||
'mkv',
|
||||
'wav',
|
||||
'flac',
|
||||
'ogg',
|
||||
-- Other binary formats
|
||||
'db',
|
||||
'sqlite',
|
||||
'dat',
|
||||
'bin',
|
||||
'iso',
|
||||
}
|
||||
|
||||
for _, binary_ext in ipairs(binary_extensions) do
|
||||
if ext == binary_ext then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if M.config.binary_file_threshold <= 0 then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
|
||||
if err or not fd then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_read(fd, M.config.binary_file_threshold, 0, function(read_err, chunk)
|
||||
vim.uv.fs_close(fd)
|
||||
|
||||
vim.schedule(function()
|
||||
if read_err or not chunk then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
if chunk:find('\0') then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
|
||||
local printable_count = 0
|
||||
local total_count = #chunk
|
||||
|
||||
for i = 1, total_count do
|
||||
local byte = chunk:byte(i)
|
||||
-- Printable ASCII range + common control chars (tab, newline, carriage return)
|
||||
if (byte >= 32 and byte <= 126) or byte == 9 or byte == 10 or byte == 13 then
|
||||
printable_count = printable_count + 1
|
||||
end
|
||||
end
|
||||
|
||||
local printable_ratio = printable_count / total_count
|
||||
callback(printable_ratio < 0.8) -- More aggressive: If less than 80% printable, consider binary
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Check if file is binary (sync version kept for compatibility)
|
||||
--- @param file_path string Path to the file
|
||||
--- @return boolean True if file appears to be binary
|
||||
function M.is_binary_file(file_path)
|
||||
local ext = vim.fn.fnamemodify(file_path, ':e')
|
||||
local binary_extensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
'webp',
|
||||
'ico',
|
||||
'pdf',
|
||||
'ps',
|
||||
'eps',
|
||||
'heic',
|
||||
'avif',
|
||||
-- Archives
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
-- Executables
|
||||
'exe',
|
||||
'dll',
|
||||
'so',
|
||||
'dylib',
|
||||
'bin',
|
||||
-- Audio/Video
|
||||
'mp3',
|
||||
'mp4',
|
||||
'avi',
|
||||
'mkv',
|
||||
'wav',
|
||||
'flac',
|
||||
'ogg',
|
||||
'aac',
|
||||
-- Other binary formats
|
||||
'db',
|
||||
'sqlite',
|
||||
'dat',
|
||||
'bin',
|
||||
'iso',
|
||||
}
|
||||
|
||||
for _, binary_ext in ipairs(binary_extensions) do
|
||||
if ext == binary_ext then return true end
|
||||
end
|
||||
|
||||
-- For sync version, just return false for unknown extensions to avoid blocking
|
||||
-- The main preview logic will handle this with async detection
|
||||
return false
|
||||
end
|
||||
|
||||
--- Get file information
|
||||
--- @param file_path string Path to the file
|
||||
--- @return table | nil File information
|
||||
@@ -468,7 +341,7 @@ function M.get_file_info(file_path)
|
||||
}
|
||||
|
||||
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
info.filetype = detect_filetype(file_path) or 'text'
|
||||
info.filetype = utils.detect_filetype(file_path) or 'text'
|
||||
info.size_formatted = utils.format_file_size(info.size)
|
||||
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
|
||||
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
|
||||
@@ -479,7 +352,7 @@ end
|
||||
--- Create file info content without custom borders
|
||||
--- @param file table File information from search results
|
||||
--- @param info table File system information
|
||||
--- @param file_index number Index of the file in search results (for score lookup)
|
||||
--- @param file_index number|nil Index of the file in search results (for score lookup)
|
||||
--- @return table Lines for the file info content
|
||||
function M.create_file_info_content(file, info, file_index)
|
||||
local lines = {}
|
||||
@@ -536,13 +409,55 @@ function M.create_file_info_content(file, info, file_index)
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Create file info content for grep mode items.
|
||||
--- Shows grep-specific metadata: match location, frecency, file info.
|
||||
---@param item table Grep match item with file + match metadata
|
||||
---@param info table File system information from get_file_info
|
||||
---@return table Lines for the file info content
|
||||
function M.create_grep_file_info_content(item, info)
|
||||
local lines = {}
|
||||
|
||||
-- Match location info
|
||||
local match_count = item.match_ranges and #item.match_ranges or 0
|
||||
table.insert(
|
||||
lines,
|
||||
string.format('Match: line %d, col %d │ Ranges: %d', item.line_number or 0, (item.col or 0) + 1, match_count)
|
||||
)
|
||||
table.insert(
|
||||
lines,
|
||||
string.format('Byte Offset: %-12d │ Size: %s', item.byte_offset or 0, info.size_formatted or 'N/A')
|
||||
)
|
||||
table.insert(lines, string.format('Type: %-8s │ Git: %s', info.filetype or 'text', item.git_status or 'clean'))
|
||||
|
||||
-- Fuzzy match score (only available in fuzzy grep mode)
|
||||
if item.fuzzy_score then table.insert(lines, string.format('Fuzzy Score: %d', item.fuzzy_score)) end
|
||||
|
||||
-- Frecency info
|
||||
local total = item.total_frecency_score or 0
|
||||
local acc = item.access_frecency_score or 0
|
||||
local mod = item.modification_frecency_score or 0
|
||||
table.insert(lines, string.format('Frecency: total=%d, access=%d, modification=%d', total, acc, mod))
|
||||
|
||||
-- Ordering explanation
|
||||
table.insert(lines, 'Order: files sorted by frecency desc, matches by line asc')
|
||||
table.insert(lines, '')
|
||||
|
||||
-- Time information section
|
||||
table.insert(lines, 'TIMINGS')
|
||||
table.insert(lines, string.rep('─', 50))
|
||||
table.insert(lines, string.format('Modified: %s', info.modified_formatted or 'N/A'))
|
||||
table.insert(lines, string.format('Last Access: %s', info.accessed_formatted or 'N/A'))
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Preview a regular file
|
||||
--- @param file_path string Path to the file
|
||||
--- @param bufnr number Buffer number for preview
|
||||
--- @return boolean Success status
|
||||
function M.preview_file(file_path, bufnr)
|
||||
-- Early size detection to prevent memory issues
|
||||
if M.is_big_file(file_path, bufnr) then
|
||||
if M.is_big_file(file_path) then
|
||||
local info = M.get_file_info(file_path)
|
||||
local lines = {
|
||||
'File too large for preview',
|
||||
@@ -569,16 +484,20 @@ function M.preview_file(file_path, bufnr)
|
||||
if success then
|
||||
local file_config = M.get_file_config(file_path)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', M.config.line_numbers)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
||||
end
|
||||
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready)
|
||||
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
|
||||
local gen = M.state.preview_generation
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == gen then M.apply_location_highlighting(bufnr) end
|
||||
end)
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -586,10 +505,11 @@ function M.preview_file(file_path, bufnr)
|
||||
|
||||
M.state.current_file = file_path
|
||||
M.state.bufnr = bufnr
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
read_file_streaming_async(file_path, bufnr, function(content, err)
|
||||
if M.state.current_file ~= file_path then
|
||||
-- User has moved to a different file, ignore this result
|
||||
read_file_streaming_async(file_path, function(content, err, loading_more)
|
||||
if M.state.preview_generation ~= generation then
|
||||
-- Preview moved on to a different file, discard
|
||||
cleanup_file_operation()
|
||||
return
|
||||
end
|
||||
@@ -602,22 +522,32 @@ function M.preview_file(file_path, bufnr)
|
||||
end
|
||||
|
||||
if M.state.current_file == file_path then
|
||||
-- Guard against buffer being destroyed while async read was in-flight
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
M.clear_preview_visual_state(bufnr)
|
||||
set_buffer_lines(bufnr, content)
|
||||
|
||||
local file_config = M.get_file_config(file_path)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', info.filetype)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', M.config.line_numbers)
|
||||
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
||||
end
|
||||
|
||||
M.state.content_height = #content
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready)
|
||||
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready).
|
||||
-- Skip when more content is being loaded asynchronously to reach the target line —
|
||||
-- ensure_content_loaded_async will re-apply highlighting once the target is in the buffer.
|
||||
if not loading_more then
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == generation then M.apply_location_highlighting(bufnr) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -633,9 +563,9 @@ function M.preview_binary_file(file_path, bufnr)
|
||||
local lines = {}
|
||||
|
||||
set_buffer_lines(bufnr, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'text')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
|
||||
if vim.fn.executable('file') == 1 then
|
||||
local cmd = { 'file', '-b', file_path }
|
||||
@@ -686,24 +616,23 @@ end
|
||||
function M.get_file_config(file_path)
|
||||
if not M.config or not M.config.filetypes then return {} end
|
||||
|
||||
local filetype = detect_filetype(file_path) or 'text'
|
||||
local filetype = utils.detect_filetype(file_path) or 'text'
|
||||
return M.config.filetypes[filetype] or {}
|
||||
end
|
||||
|
||||
--- @param file_path string Path to the file or directory
|
||||
--- @param bufnr number Buffer number for preview
|
||||
--- @param location table|nil Optional location data for highlighting
|
||||
--- @param is_binary boolean|nil Whether the file is binary (from Rust indexer)
|
||||
--- @return boolean if the preview was successful
|
||||
function M.preview(file_path, bufnr, location)
|
||||
if not file_path or file_path == '' then
|
||||
-- Don't immediately clear - let the previous content stay visible
|
||||
-- Only clear if we really need to show "No file selected"
|
||||
-- M.clear_buffer(bufnr)
|
||||
-- set_buffer_lines(bufnr, { 'No file selected' })
|
||||
return false
|
||||
end
|
||||
function M.preview(file_path, bufnr, location, is_binary)
|
||||
if not file_path or file_path == '' then return false end
|
||||
|
||||
-- Bump generation to invalidate any in-flight async callbacks from previous previews
|
||||
M.state.preview_generation = M.state.preview_generation + 1
|
||||
|
||||
if M.state.file_handle then
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
M.state.file_handle:close()
|
||||
M.state.file_handle = nil
|
||||
end
|
||||
@@ -722,11 +651,8 @@ function M.preview(file_path, bufnr, location)
|
||||
|
||||
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
|
||||
|
||||
local win_width = vim.api.nvim_win_get_width(M.state.winid) - 2
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.winid) - 2
|
||||
|
||||
return image.display_image(file_path, bufnr, win_width, win_height)
|
||||
elseif M.is_binary_file(file_path) then
|
||||
return image.display_image(file_path, bufnr)
|
||||
elseif is_binary then
|
||||
return M.preview_binary_file(file_path, bufnr)
|
||||
else
|
||||
return M.preview_file(file_path, bufnr)
|
||||
@@ -778,8 +704,9 @@ end
|
||||
function M.set_preview_window(winid) M.state.winid = winid end
|
||||
|
||||
--- Update file info buffer
|
||||
--- @param file table File information from search results
|
||||
--- @param file table File information from search results (or grep match item)
|
||||
--- @param bufnr number Buffer number for file info
|
||||
--- @param file_index number|nil Index of the file in search results (for score lookup, file mode only)
|
||||
--- @return boolean Success status
|
||||
function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
if not file then
|
||||
@@ -793,13 +720,24 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
return false
|
||||
end
|
||||
|
||||
local file_info_lines = M.create_file_info_content(file, info, file_index)
|
||||
-- Detect grep mode items by the presence of line_number (grep-specific field)
|
||||
local file_info_lines
|
||||
if file.line_number ~= nil then
|
||||
file_info_lines = M.create_grep_file_info_content(file, info)
|
||||
else
|
||||
file_info_lines = M.create_file_info_content(file, info, file_index)
|
||||
end
|
||||
set_buffer_lines(bufnr, file_info_lines)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
|
||||
-- Set wrap on the window (wrap is window-local, not buffer-local)
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
for _, win in ipairs(wins) do
|
||||
if vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_option_value('wrap', false, { win = win }) end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -839,15 +777,18 @@ function M.clear_buffer(bufnr)
|
||||
|
||||
pcall(vim.treesitter.stop, bufnr)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', '')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'syntax', '')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
|
||||
set_buffer_lines(bufnr, {})
|
||||
end
|
||||
|
||||
function M.clear()
|
||||
-- Bump generation to invalidate any in-flight async callbacks
|
||||
M.state.preview_generation = M.state.preview_generation + 1
|
||||
|
||||
cleanup_file_operation()
|
||||
|
||||
M.state.loaded_lines = 0
|
||||
@@ -878,12 +819,20 @@ function M.apply_location_highlighting(bufnr)
|
||||
|
||||
if not M.state.location then return end
|
||||
|
||||
-- Apply highlighting
|
||||
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
|
||||
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
local target_line = location_utils.get_target_line(M.state.location)
|
||||
if target_line then M.scroll_to_line(target_line) end
|
||||
if target_line then
|
||||
local buffer_lines = vim.api.nvim_buf_line_count(bufnr)
|
||||
if target_line > buffer_lines and M.state.has_more_content then
|
||||
-- Target line is beyond loaded content — load more first.
|
||||
-- ensure_content_loaded_async will re-apply highlighting when done.
|
||||
ensure_content_loaded_async(target_line)
|
||||
return
|
||||
end
|
||||
M.scroll_to_line(target_line)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+47
-55
@@ -2,30 +2,6 @@
|
||||
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
|
||||
local M = {}
|
||||
|
||||
--- Render Context passed to renderer functions
|
||||
--- @class RenderContext
|
||||
--- @field config table User configuration from conf.get()
|
||||
--- @field items table[] Array of file items being rendered
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
--- @field win_width number Window width in columns
|
||||
--- @field max_path_width number Maximum width for file paths
|
||||
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
|
||||
--- @field prompt_position string Prompt position: 'top' or 'bottom'
|
||||
--- @field has_combo boolean Whether combo boost is active
|
||||
--- @field combo_header_line string Formatted combo header line (if has_combo)
|
||||
--- @field combo_header_text_len number Length of combo header text (if has_combo)
|
||||
--- @field combo_item_index number Index of item with combo (usually 1)
|
||||
--- @field display_start number Start index for displayed items
|
||||
--- @field display_end number End index for displayed items
|
||||
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
|
||||
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
|
||||
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
|
||||
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
|
||||
--- @field selected_files table<string, boolean> Map of selected file paths
|
||||
--- @field query string Current search query
|
||||
--- @field renderer table|nil Custom renderer (if provided via opts)
|
||||
|
||||
--- File Item structure from Rust
|
||||
--- @class FileItem
|
||||
--- @field path string Absolute file path
|
||||
@@ -37,28 +13,23 @@ local M = {}
|
||||
--- @field total_frecency_score number Total frecency score
|
||||
--- @field access_frecency_score number Access-based frecency score
|
||||
--- @field modification_frecency_score number Modification-based frecency score
|
||||
--- @field git_status number|nil Git status enum (if file is in git repo)
|
||||
|
||||
--- Renderer Interface:
|
||||
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
|
||||
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
|
||||
--- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
|
||||
--- internal:
|
||||
--- @field _has_group_header boolean Internal flag for render_line to indicate if this item has a combo header line (not from Rust)
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @return string[] Array of line strings (1 or 2 lines if combo)
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local lines = {}
|
||||
|
||||
-- Check if this should have combo header (first item with combo boost)
|
||||
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
|
||||
|
||||
if has_combo then table.insert(lines, ctx.combo_header_line) end
|
||||
|
||||
-- Get icon
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon, _ = icons.get_icon(item.name, item.extension, false)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
@@ -72,7 +43,7 @@ function M.render_line(item, ctx, item_idx)
|
||||
if mod >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access >= 4 then
|
||||
indicator = '⭐'
|
||||
indicator = '⭐️'
|
||||
elseif total >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total >= 1 then
|
||||
@@ -100,7 +71,7 @@ end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
@@ -126,7 +97,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = line_idx,
|
||||
hl_group = ctx.config.hl.active_file,
|
||||
hl_group = ctx.config.hl.cursor,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
@@ -135,7 +106,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
-- 2. Icon
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_hl = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
0,
|
||||
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
|
||||
)
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
@@ -143,15 +120,27 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
local filename_start = #icon + 1
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
filename_start,
|
||||
{ end_col = filename_start + #filename, hl_group = git_text_hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. Frecency indicator
|
||||
if ctx.debug_enabled then
|
||||
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
|
||||
if start_pos then
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
|
||||
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
|
||||
if start_pos and end_pos then
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
start_pos - 1,
|
||||
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -161,22 +150,26 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
|
||||
)
|
||||
end
|
||||
|
||||
-- 6. Current file
|
||||
if is_current_file then
|
||||
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
|
||||
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
|
||||
local hl
|
||||
if is_cursor then
|
||||
hl = ctx.config.hl.cursor
|
||||
else
|
||||
hl = 'Comment'
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
@@ -187,10 +180,10 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
local base_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
@@ -199,7 +192,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.active_file
|
||||
border_hl = ctx.config.hl.cursor
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
@@ -215,7 +208,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
elseif is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = ctx.config.hl.active_file,
|
||||
sign_hl_group = ctx.config.hl.cursor,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
@@ -234,13 +227,12 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,10 +38,15 @@ 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
|
||||
|
||||
@@ -109,20 +109,20 @@ function M.setup_highlights()
|
||||
vim.cmd([[
|
||||
" Symbol highlights
|
||||
highlight default FFFGitStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
" Thin border highlights
|
||||
|
||||
" Thin border highlights
|
||||
highlight default FFFGitSignStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitSignRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitSignUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
|
||||
" Fallback to GitSigns highlights if they exist
|
||||
highlight default link FFFGitSignStaged GitSignsAdd
|
||||
highlight default link FFFGitSignModified GitSignsChange
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
--- 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 = ' '
|
||||
-- vim.json.decode may return Blobs for strings with NUL bytes; coerce to string.
|
||||
local raw_content = item.line_content
|
||||
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
|
||||
local content = raw_content
|
||||
|
||||
-- Indent + location + separator + content
|
||||
local indent = ' '
|
||||
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,293 @@
|
||||
--- 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, '')
|
||||
if ctx.mode == 'grep' and ctx.suggestion_source == 'files' then
|
||||
-- Grep mode with no results — hint about mode cycling to fuzzy search
|
||||
local config = require('fff.conf').get()
|
||||
local keybind = config.keymaps.cycle_grep_modes
|
||||
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
|
||||
table.insert(suggestion_header_lines, ' No results, try ' .. keybind .. ' to fuzzy search')
|
||||
else
|
||||
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
|
||||
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
|
||||
end
|
||||
table.insert(suggestion_header_lines, '')
|
||||
end
|
||||
|
||||
-- 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') or line:match('^%s+No results,')) then
|
||||
pcall(
|
||||
vim.api.nvim_buf_set_extmark,
|
||||
list_buf,
|
||||
ns_id,
|
||||
i,
|
||||
0,
|
||||
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
@@ -38,6 +38,11 @@ function M.highlight_location(bufnr, location, namespace)
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local extmarks = {}
|
||||
|
||||
-- Grep mode: highlight all occurrences of the search pattern across visible lines
|
||||
if location.grep_query and location.grep_query ~= '' then
|
||||
return M.highlight_grep_matches(bufnr, location, namespace)
|
||||
end
|
||||
|
||||
if location.line then
|
||||
local target_line = math.max(1, math.min(location.line, line_count))
|
||||
|
||||
@@ -129,6 +134,98 @@ function M.highlight_location(bufnr, location, namespace)
|
||||
return #extmarks > 0 and extmarks or nil
|
||||
end
|
||||
|
||||
--- Highlight all occurrences of a grep pattern in the preview buffer.
|
||||
--- For plain text and regex modes: highlights every match on all loaded lines
|
||||
--- using Lua string.find with the query text.
|
||||
--- For fuzzy mode: uses the pre-computed match byte offsets from Rust on the
|
||||
--- target line only, since the fuzzy needle (e.g. "shcema") won't match via
|
||||
--- literal search against the actual content (e.g. "schema").
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param location table Location with .grep_query, .line, optional .col, optional .fuzzy_match_ranges
|
||||
--- @param namespace number Namespace for extmarks
|
||||
--- @return table|nil Highlight extmark details for cleanup
|
||||
function M.highlight_grep_matches(bufnr, location, namespace)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return nil end
|
||||
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local extmarks = {}
|
||||
|
||||
-- Target line highlighting is handled by the native `cursorline` window
|
||||
-- option, which is enabled on the preview window in grep mode (picker_ui.lua).
|
||||
-- The cursor is positioned on the target line by preview.scroll_to_line(),
|
||||
-- giving standard CursorLine background + CursorLineNr line number styling
|
||||
-- without conflicting with IncSearch match highlights.
|
||||
|
||||
-- Fuzzy mode: use pre-computed byte offsets from Rust's match_indices.
|
||||
-- These are the exact matched character positions within the line, already
|
||||
-- computed by the SIMD scoring + reference smith-waterman traceback.
|
||||
-- We only highlight the target line since each fuzzy result has its own
|
||||
-- unique set of matched positions.
|
||||
if location.fuzzy_match_ranges and location.line then
|
||||
local target_line = math.max(1, math.min(location.line, line_count))
|
||||
for _, range in ipairs(location.fuzzy_match_ranges) do
|
||||
local start_byte = range[1] -- 0-based byte offset
|
||||
local end_byte = range[2] -- 0-based exclusive end
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, start_byte, {
|
||||
end_col = end_byte,
|
||||
hl_group = 'IncSearch',
|
||||
priority = 1000,
|
||||
})
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
|
||||
end
|
||||
return #extmarks > 0 and extmarks or nil
|
||||
end
|
||||
|
||||
local query = location.grep_query
|
||||
|
||||
-- Extract the actual search text from the grep query (strip file constraints like *.rs /src/)
|
||||
-- The query parser uses space-separated tokens; the first non-constraint token is the pattern.
|
||||
-- Simple heuristic: strip tokens that look like constraints (start with *, /, or !)
|
||||
local search_text = query
|
||||
local parts = vim.split(query, '%s+')
|
||||
local text_parts = {}
|
||||
for _, part in ipairs(parts) do
|
||||
if part ~= '' and not part:match('^[%*!/]') and not part:match('^%.') then table.insert(text_parts, part) end
|
||||
end
|
||||
if #text_parts > 0 then search_text = text_parts[1] end
|
||||
|
||||
if not search_text or search_text == '' then return nil end
|
||||
|
||||
-- Build case-insensitive pattern if the query has no uppercase (smart case)
|
||||
local has_upper = search_text:match('[A-Z]')
|
||||
local escaped = vim.pesc(search_text)
|
||||
|
||||
-- Highlight pattern occurrences in a window around the target line.
|
||||
-- Limit to ±200 lines from target to keep it fast for large files.
|
||||
local scan_start = 1
|
||||
local scan_end = line_count
|
||||
if location.line then
|
||||
scan_start = math.max(1, location.line - 200)
|
||||
scan_end = math.min(line_count, location.line + 200)
|
||||
end
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, scan_start - 1, scan_end, false)
|
||||
for idx, line in ipairs(lines) do
|
||||
local i = scan_start + idx - 1
|
||||
local search_line = has_upper and line or line:lower()
|
||||
local search_pat = has_upper and escaped or escaped:lower()
|
||||
local start_pos = 1
|
||||
while true do
|
||||
local s, e = search_line:find(search_pat, start_pos, true)
|
||||
if not s then break end
|
||||
-- s and e are 1-based byte positions; extmarks need 0-based
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, i - 1, s - 1, {
|
||||
end_col = e,
|
||||
hl_group = 'IncSearch',
|
||||
priority = 1000,
|
||||
})
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = i - 1 }) end
|
||||
start_pos = e + 1
|
||||
end
|
||||
end
|
||||
|
||||
return #extmarks > 0 and extmarks or nil
|
||||
end
|
||||
|
||||
--- Clear location highlights from a buffer
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param namespace number Namespace for extmarks
|
||||
|
||||
+28
-2
@@ -17,6 +17,33 @@ function M.find_files(opts)
|
||||
end
|
||||
end
|
||||
|
||||
--- 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 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
|
||||
|
||||
--- Changes the directory indexed by the file picker to the git root and opens the file picker
|
||||
--- @deprecated Use `find_files` instead
|
||||
function M.find_in_git_root()
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, git_root = pcall(fuzzy.get_git_root)
|
||||
@@ -180,7 +207,6 @@ end
|
||||
--- is found and we are about to inline open it
|
||||
--- @param open_cb function|nil Optional callback function to execute after opening the file
|
||||
function M.open_file_under_cursor(open_cb)
|
||||
local filename = vim.fn.expand('<cfile>')
|
||||
local full_path_with_suffix = vim.fn.expand('<cWORD>')
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
@@ -189,7 +215,7 @@ function M.open_file_under_cursor(open_cb)
|
||||
return
|
||||
end
|
||||
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, metadata, location, get_file_score)
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
|
||||
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
|
||||
if open_cb and type(open_cb) == 'function' then open_cb(files[1].path) end
|
||||
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].path)))
|
||||
|
||||
+1107
-548
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
-- rendering in a separate buffer to overflow the border
|
||||
if not scrollbar_exists then
|
||||
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scrollbar_state.buf })
|
||||
|
||||
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
|
||||
relative = 'editor',
|
||||
@@ -52,7 +52,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
})
|
||||
|
||||
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
|
||||
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
|
||||
vim.api.nvim_set_option_value('winhighlight', scrollbar_hl, { win = scrollbar_state.win })
|
||||
|
||||
scrollbar_state.ever_shown = true
|
||||
end
|
||||
@@ -83,9 +83,9 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
end
|
||||
end
|
||||
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
|
||||
pcall(vim.api.nvim_set_option_value, 'modifiable', true, { buf = scrollbar_state.buf })
|
||||
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
|
||||
pcall(vim.api.nvim_set_option_value, 'modifiable', false, { buf = scrollbar_state.buf })
|
||||
|
||||
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
|
||||
if thumb_size > 0 then
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
local utils = require('fff.utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Per-language scratch buffer cache
|
||||
--- @type table<string, number>
|
||||
local scratch_bufs = {}
|
||||
|
||||
--- Get or create a scratch buffer for a given treesitter language.
|
||||
--- The buffer is reused across calls — content is overwritten each time.
|
||||
--- @param lang string Treesitter language name
|
||||
--- @return number buf Buffer handle
|
||||
local function get_scratch_buf(lang)
|
||||
local buf = scratch_bufs[lang]
|
||||
if buf and vim.api.nvim_buf_is_valid(buf) then return buf end
|
||||
|
||||
buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_name(buf, 'fff://treesitter/' .. lang)
|
||||
vim.bo[buf].bufhidden = 'hide'
|
||||
vim.bo[buf].buftype = 'nofile'
|
||||
vim.bo[buf].swapfile = false
|
||||
vim.bo[buf].undolevels = -1
|
||||
scratch_bufs[lang] = buf
|
||||
return buf
|
||||
end
|
||||
|
||||
--- Resolve a filename to a treesitter language.
|
||||
--- Returns nil if no parser is available.
|
||||
--- @param filename string File name (e.g. "foo.rs")
|
||||
--- @return string|nil lang Treesitter language name, or nil
|
||||
function M.lang_from_filename(filename)
|
||||
if not filename or filename == '' then return nil end
|
||||
|
||||
local ft = utils.detect_filetype(filename) or 'text'
|
||||
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
|
||||
if not lang_ok or not lang then lang = ft end
|
||||
|
||||
-- Check if the parser is actually installed
|
||||
local has_parser = pcall(vim.treesitter.language.add, lang)
|
||||
if not has_parser then return nil end
|
||||
|
||||
return lang
|
||||
end
|
||||
|
||||
--- Extract treesitter highlights for a single line of code.
|
||||
--- Returns an array of { col, end_col, hl_group } tables where col/end_col
|
||||
--- are 0-based byte offsets within the input string.
|
||||
---
|
||||
--- @param text string The line of code to highlight
|
||||
--- @param lang string Treesitter language name (from lang_from_filename)
|
||||
--- @return table[] highlights Array of { col: number, end_col: number, hl_group: string }
|
||||
function M.get_line_highlights(text, lang)
|
||||
if not text or text == '' or not lang then return {} end
|
||||
|
||||
local buf = get_scratch_buf(lang)
|
||||
|
||||
-- Write the single line into the scratch buffer
|
||||
vim.bo[buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text })
|
||||
vim.bo[buf].modifiable = false
|
||||
|
||||
-- Parse with treesitter
|
||||
local ok, parser = pcall(vim.treesitter.get_parser, buf, lang)
|
||||
if not ok or not parser then return {} end
|
||||
|
||||
local parse_ok = pcall(parser.parse, parser, true)
|
||||
if not parse_ok then return {} end
|
||||
|
||||
local highlights = {}
|
||||
|
||||
parser:for_each_tree(function(tstree, tree)
|
||||
if not tstree then return end
|
||||
local root = tstree:root()
|
||||
if not root then return end
|
||||
|
||||
local tree_lang = tree:lang()
|
||||
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
|
||||
if not query_ok or not query then return end
|
||||
|
||||
for capture, node, _ in query:iter_captures(root, buf, 0, 1) do
|
||||
local name = query.captures[capture]
|
||||
if name and name ~= 'spell' and name ~= 'conceal' then
|
||||
local start_row, start_col, end_row, end_col = node:range()
|
||||
-- Only process highlights on line 0 (our single line)
|
||||
if start_row == 0 then
|
||||
if end_row > 0 then end_col = #text end -- multi-line node: clamp to line end
|
||||
if start_col < end_col then
|
||||
highlights[#highlights + 1] = {
|
||||
col = start_col,
|
||||
end_col = end_col,
|
||||
hl_group = '@' .. name .. '.' .. tree_lang,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return highlights
|
||||
end
|
||||
|
||||
--- Clean up all scratch buffers.
|
||||
--- Called when the picker closes.
|
||||
function M.cleanup()
|
||||
for lang, buf in pairs(scratch_bufs) do
|
||||
if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end
|
||||
scratch_bufs[lang] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+30
-1
@@ -17,6 +17,34 @@ function M.format_file_size(size)
|
||||
end
|
||||
end
|
||||
|
||||
local function get_fixed_filetype_detection(extension)
|
||||
local extension_map = {
|
||||
ts = 'typescript',
|
||||
tex = 'latex',
|
||||
md = 'markdown',
|
||||
txt = 'text',
|
||||
}
|
||||
|
||||
return extension_map[extension]
|
||||
end
|
||||
|
||||
--- Detect filetype with various fallbacks
|
||||
--- @param file_path string the filetype
|
||||
--- @return string detected filetype
|
||||
function M.detect_filetype(file_path)
|
||||
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
|
||||
if has_plenary then
|
||||
local detected = plenary_filetype.detect(file_path, {})
|
||||
if detected and detected ~= '' then return detected end
|
||||
end
|
||||
|
||||
local builtin_filetype = vim.filetype.match({ filename = file_path })
|
||||
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
|
||||
|
||||
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
return get_fixed_filetype_detection(extension)
|
||||
end
|
||||
|
||||
--- Safely resolve a config value that can be either a static value or a function
|
||||
--- @param config_value any The config value (can be function or static value)
|
||||
--- @param terminal_width number Terminal width for function calls
|
||||
@@ -24,7 +52,7 @@ end
|
||||
--- @param validator function Function to validate the result
|
||||
--- @param fallback any Fallback value if function fails or returns invalid value
|
||||
--- @param error_context string Context for error messages
|
||||
--- @return any The resolved and validated value
|
||||
--- @return number The resolved and validated value
|
||||
function M.resolve_config_value(config_value, terminal_width, terminal_height, validator, fallback, error_context)
|
||||
if type(config_value) == 'function' then
|
||||
local success, result = pcall(config_value, terminal_width, terminal_height)
|
||||
@@ -38,6 +66,7 @@ function M.resolve_config_value(config_value, terminal_width, terminal_height, v
|
||||
return fallback
|
||||
end
|
||||
else
|
||||
if config_value == nil or not validator(config_value) then return fallback end
|
||||
return config_value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,18 +11,24 @@ function M.get_triple()
|
||||
if os_name == 'darwin' then
|
||||
os_name = 'apple-darwin'
|
||||
elseif os_name == 'linux' then
|
||||
-- Detect if we're on musl or glibc
|
||||
local handle = io.popen('ldd --version 2>&1')
|
||||
if handle then
|
||||
local output = handle:read('*a')
|
||||
handle:close()
|
||||
if output and output:match('musl') then
|
||||
os_name = 'unknown-linux-musl'
|
||||
-- Detect Android/Termux before checking musl/glibc.
|
||||
-- Termux uses Bionic libc (not glibc or musl) and has no ldd.
|
||||
if os.getenv('TERMUX_VERSION') or os.getenv('ANDROID_ROOT') then
|
||||
os_name = 'linux-android'
|
||||
else
|
||||
-- Detect if we're on musl or glibc
|
||||
local handle = io.popen('ldd --version 2>&1')
|
||||
if handle then
|
||||
local output = handle:read('*a')
|
||||
handle:close()
|
||||
if output and output:match('musl') then
|
||||
os_name = 'unknown-linux-musl'
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
|
||||
os_name = 'pc-windows-msvc'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": ["packages/fff-bun", "packages/fff-mcp"],
|
||||
"packageManager": "bun@1.3.9",
|
||||
"scripts": {
|
||||
"format": "biome format --write",
|
||||
"format:check": "biome format",
|
||||
"lint": "biome lint",
|
||||
"check": "biome check --write",
|
||||
"check:ci": "biome check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-darwin-arm64",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for macOS ARM64 (Apple Silicon)",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "libfff_c.dylib",
|
||||
"files": ["libfff_c.dylib"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-darwin-arm64"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-darwin-x64",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for macOS x64 (Intel)",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"],
|
||||
"main": "libfff_c.dylib",
|
||||
"files": ["libfff_c.dylib"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-darwin-x64"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-linux-arm64-gnu",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Linux ARM64 (glibc)",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "libfff_c.so",
|
||||
"files": ["libfff_c.so"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-linux-arm64-gnu"
|
||||
},
|
||||
"libc": ["glibc"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-linux-arm64-musl",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Linux ARM64 (musl)",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "libfff_c.so",
|
||||
"files": ["libfff_c.so"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-linux-arm64-musl"
|
||||
},
|
||||
"libc": ["musl"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-linux-x64-gnu",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Linux x64 (glibc)",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "libfff_c.so",
|
||||
"files": ["libfff_c.so"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-linux-x64-gnu"
|
||||
},
|
||||
"libc": ["glibc"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user