Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6cb66b597 | |||
| 64861f8142 | |||
| eb5f2b3648 | |||
| fcdf4a9172 | |||
| 1001eb8b5e | |||
| f0ce2dd50d | |||
| 1c2a1c1204 | |||
| 66bdfff454 | |||
| 1e50f8df80 | |||
| 736c41ecd6 | |||
| ac8df4c9e4 | |||
| e3e534f4ad | |||
| 764e3ecf18 | |||
| 10a27f9678 | |||
| 66e712e066 | |||
| c5a3f89c5e | |||
| 29f81a141f | |||
| d2e3993398 |
+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"]
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
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
|
||||
@@ -44,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
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- name: Build Rust binary
|
||||
if: ${{ !matrix.target }}
|
||||
run: cargo build --release -p fff-nvim
|
||||
run: cargo build --release -p fff-nvim --features zlob
|
||||
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
|
||||
+164
-15
@@ -2,7 +2,7 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/interchangable-ffi]
|
||||
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,15 +79,29 @@ 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
|
||||
@@ -92,7 +112,7 @@ jobs:
|
||||
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
|
||||
@@ -137,6 +157,13 @@ jobs:
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_c.so
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
@@ -174,7 +201,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
|
||||
|
||||
@@ -183,15 +210,28 @@ jobs:
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -202,10 +242,11 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Prepare npm package
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy the built binary into the platform npm package directory
|
||||
@@ -218,17 +259,104 @@ jobs:
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
- name: Upload npm package artifact
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
path: packages/${{ matrix.npm_package }}/
|
||||
|
||||
build-mcp:
|
||||
name: Build MCP ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
## Linux builds (using cargo-zigbuild)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/fff-mcp
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/fff-mcp
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/fff-mcp
|
||||
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff-mcp.exe
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@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
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mcp-${{ matrix.target }}
|
||||
path: fff-mcp-${{ matrix.target }}*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: [build-nvim, build-c]
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
# do not create releases on the forks (no permissions)
|
||||
if: github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dmtrKovalenko'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -269,6 +397,19 @@ jobs:
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Flatten MCP artifacts
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
for dir in mcp-*/; do
|
||||
for file in "$dir"*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
mv "$file" "./$filename"
|
||||
fi
|
||||
done
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Remove npm package artifacts from release binaries
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
@@ -310,6 +451,14 @@ 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]
|
||||
@@ -403,10 +552,10 @@ jobs:
|
||||
|
||||
comment-on-pr:
|
||||
name: Comment on PR
|
||||
needs: [build-nvim, build-c]
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
# comments doesn't work on forks
|
||||
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+556
-41
@@ -45,12 +45,56 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -63,12 +107,29 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
@@ -93,7 +154,7 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
@@ -154,6 +215,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -252,6 +319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -260,8 +328,22 @@ version = "4.5.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -270,6 +352,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
@@ -294,7 +382,7 @@ dependencies = [
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
@@ -315,7 +403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -377,6 +465,40 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
@@ -433,12 +555,24 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.13"
|
||||
@@ -473,6 +607,7 @@ name = "fff-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"aho-corasick",
|
||||
"bindet",
|
||||
"blake3",
|
||||
"chrono",
|
||||
@@ -482,6 +617,7 @@ dependencies = [
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-searcher",
|
||||
"heed",
|
||||
@@ -498,16 +634,35 @@ dependencies = [
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fff-core",
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"mimalloc",
|
||||
"rmcp",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-nvim"
|
||||
version = "0.1.0"
|
||||
@@ -539,9 +694,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -580,6 +732,94 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
@@ -670,6 +910,18 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heed"
|
||||
version = "0.22.0"
|
||||
@@ -824,6 +1076,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.0.3"
|
||||
@@ -861,6 +1119,16 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.0"
|
||||
@@ -889,9 +1157,15 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
@@ -901,6 +1175,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -1141,36 +1424,14 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
|
||||
dependencies = [
|
||||
"multiversion-macros",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion-macros"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3f70b45907246d13fbe88cee200ebba81c9b77476028133a527c88bd875bc7"
|
||||
checksum = "8c3404d265de0390885288ecf22fcdc4fb77519fe58168bac3d26ce65033b18b"
|
||||
dependencies = [
|
||||
"multiversion",
|
||||
"rayon",
|
||||
"itertools 0.14.0",
|
||||
"raw-cpuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1276,6 +1537,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
@@ -1327,6 +1594,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -1509,6 +1782,15 @@ dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.10.0"
|
||||
@@ -1549,6 +1831,26 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
@@ -1593,6 +1895,41 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2cb14cb9278a12eae884c9f3c0cfeca2cc28f361211206424a1d7abed95f090"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a02ea81d9482b07e1fe156ac7cf98b6823d51fb84531936a5e1cbb4eec31ad5"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -1639,6 +1976,32 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -1665,6 +2028,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.140"
|
||||
@@ -1677,6 +2051,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1692,12 +2075,28 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -1716,6 +2115,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
@@ -1728,6 +2137,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.104"
|
||||
@@ -1759,12 +2174,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-features"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.20.0"
|
||||
@@ -1878,6 +2287,88 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
@@ -1974,6 +2465,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -2208,6 +2705,15 @@ dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
@@ -2394,6 +2900,15 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.39.0"
|
||||
@@ -2509,9 +3024,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlob"
|
||||
version = "1.2.9"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "674f4e74544c0a00887c0dff7862fe13742de8c5dbcf6db9eac0643362554d44"
|
||||
checksum = "07934fb13f0f4e14281bab6b0e984cfc03891d2cf5b0a8bd0ab14fd240e21106"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.9.1",
|
||||
|
||||
+4
-2
@@ -2,6 +2,7 @@
|
||||
members = [
|
||||
"crates/fff-c",
|
||||
"crates/fff-core",
|
||||
"crates/fff-mcp",
|
||||
"crates/fff-nvim",
|
||||
"crates/fff-query-parser",
|
||||
"crates/fff-searcher",
|
||||
@@ -22,16 +23,17 @@ git2 = { version = "0.20.2", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
globset = "0.4"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-searcher = { path = "crates/fff-searcher" }
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
zlob = "1.2.9"
|
||||
zlob = "1.3.0"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = "0.8.1"
|
||||
neo_frizbee = "0.8.2"
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.7"
|
||||
once_cell = "1.20.2"
|
||||
|
||||
@@ -3,7 +3,7 @@ PLENARY_DIR ?= ../plenary.nvim
|
||||
.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,7 +12,7 @@ test-setup:
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --workspace
|
||||
cargo test --workspace --features zlob
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
@@ -24,6 +24,10 @@ prepare-bun: build
|
||||
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/
|
||||
@@ -34,5 +38,18 @@ 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-mcp.sh](./install-mcp.sh) if you want to review it before running.
|
||||
|
||||
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
|
||||
Here is an example addition to `CLAUDE.md` that works perfectly:
|
||||
|
||||
```sh
|
||||
# CLAUDE.md
|
||||
For any file search or grep in the current git indexed directory use fff tools
|
||||
```
|
||||
|
||||
## Neovim guide
|
||||
|
||||
Here is some demo on the linux repository (100k files, 8GB) but you better fill it yourself and see the magic
|
||||
|
||||
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
|
||||
### Installation
|
||||
|
||||
FFF.nvim requires neovim 0.10.0 or higher
|
||||
|
||||
#### lazy.nvim
|
||||
|
||||
```lua
|
||||
@@ -160,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 },
|
||||
@@ -252,9 +266,8 @@ require('fff').setup({
|
||||
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,
|
||||
@@ -281,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
|
||||
@@ -301,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:
|
||||
@@ -365,6 +374,32 @@ 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:
|
||||
@@ -458,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,6 +8,10 @@ license = "MIT"
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[dependencies]
|
||||
mimalloc.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -82,6 +82,10 @@ pub struct InitOptions {
|
||||
/// grep search is as fast as subsequent ones (optional, defaults to false)
|
||||
#[serde(default)]
|
||||
pub warmup_mmap_cache: bool,
|
||||
/// AI mode: automatically track frecency for all file modifications detected
|
||||
/// by the background watcher (optional, defaults to false)
|
||||
#[serde(default)]
|
||||
pub ai_mode: bool,
|
||||
}
|
||||
|
||||
/// Search options (JSON-deserializable)
|
||||
@@ -241,6 +245,37 @@ impl SearchResultJson {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-grep (Aho-Corasick multi-pattern) types
|
||||
// ============================================================================
|
||||
|
||||
/// Multi-grep search options (JSON-deserializable)
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct MultiGrepOptionsJson {
|
||||
/// Patterns to search (OR logic — matches lines containing any pattern)
|
||||
pub patterns: Vec<String>,
|
||||
/// Optional constraint query like "*.rs" or "/src/"
|
||||
pub constraints: Option<String>,
|
||||
/// Maximum file size to search (bytes, default: 10MB)
|
||||
pub max_file_size: Option<u64>,
|
||||
/// Maximum matches per file (default: 0 = unlimited)
|
||||
pub max_matches_per_file: Option<usize>,
|
||||
/// Smart case: case-insensitive if all patterns are lowercase (default: true)
|
||||
pub smart_case: Option<bool>,
|
||||
/// File-based pagination offset (default: 0)
|
||||
pub file_offset: Option<usize>,
|
||||
/// Maximum matches to return per page (default: 50)
|
||||
pub page_limit: Option<usize>,
|
||||
/// Time budget in milliseconds, 0 = unlimited (default: 0)
|
||||
pub time_budget_ms: Option<u64>,
|
||||
/// Number of context lines before each match (default: 0)
|
||||
pub before_context: Option<usize>,
|
||||
/// Number of context lines after each match (default: 0)
|
||||
pub after_context: Option<usize>,
|
||||
/// Whether to classify matches as definition lines (default: false)
|
||||
pub classify_definitions: Option<bool>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grep (live search) types
|
||||
// ============================================================================
|
||||
@@ -262,6 +297,12 @@ pub struct GrepSearchOptionsJson {
|
||||
pub mode: Option<String>,
|
||||
/// Time budget in milliseconds, 0 = unlimited (default: 0)
|
||||
pub time_budget_ms: Option<u64>,
|
||||
/// Number of context lines before each match (default: 0)
|
||||
pub before_context: Option<usize>,
|
||||
/// Number of context lines after each match (default: 0)
|
||||
pub after_context: Option<usize>,
|
||||
/// Whether to classify matches as definition lines (default: false)
|
||||
pub classify_definitions: Option<bool>,
|
||||
}
|
||||
|
||||
/// A single grep match for JSON serialization
|
||||
@@ -288,6 +329,14 @@ pub struct GrepMatchJson {
|
||||
/// Fuzzy match score (only in fuzzy mode)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fuzzy_score: Option<u16>,
|
||||
/// Whether the matched line is a code definition (struct, fn, class, etc.)
|
||||
pub is_definition: bool,
|
||||
/// Lines before the match (context)
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub context_before: Vec<String>,
|
||||
/// Lines after the match (context)
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub context_after: Vec<String>,
|
||||
}
|
||||
|
||||
impl GrepMatchJson {
|
||||
@@ -313,6 +362,9 @@ impl GrepMatchJson {
|
||||
.map(|&(start, end)| [start, end])
|
||||
.collect(),
|
||||
fuzzy_score: m.fuzzy_score,
|
||||
is_definition: m.is_definition,
|
||||
context_before: m.context_before.clone(),
|
||||
context_after: m.context_after.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
-73
@@ -25,13 +25,12 @@ mod ffi_types;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{DbHealthChecker, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{DbHealthChecker, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{SharedFrecency, SharedPicker};
|
||||
use ffi_types::{FffResult, GrepSearchOptionsJson, InitOptions, ScanProgress, SearchOptions};
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
use ffi_types::{
|
||||
FffResult, GrepSearchOptionsJson, InitOptions, MultiGrepOptionsJson, ScanProgress,
|
||||
SearchOptions,
|
||||
};
|
||||
|
||||
/// Opaque fff_handle holding all per-instance state.
|
||||
///
|
||||
@@ -109,6 +108,12 @@ pub unsafe extern "C" fn fff_create(opts_json: *const c_char) -> *mut FffResult
|
||||
}
|
||||
};
|
||||
*guard = Some(tracker);
|
||||
drop(guard);
|
||||
FrecencyTracker::spawn_gc(
|
||||
Arc::clone(&shared_frecency),
|
||||
frecency_path,
|
||||
opts.use_unsafe_no_lock,
|
||||
);
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
|
||||
}
|
||||
@@ -137,10 +142,17 @@ pub unsafe extern "C" fn fff_create(opts_json: *const c_char) -> *mut FffResult
|
||||
}
|
||||
}
|
||||
|
||||
let mode = if opts.ai_mode {
|
||||
FFFMode::Ai
|
||||
} else {
|
||||
FFFMode::Neovim
|
||||
};
|
||||
|
||||
// Initialize file picker (writes directly into shared_picker)
|
||||
if let Err(e) = FilePicker::new_with_shared_state(
|
||||
opts.base_path,
|
||||
opts.warmup_mmap_cache,
|
||||
mode,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
) {
|
||||
@@ -317,7 +329,12 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
_ => fff_core::GrepMode::PlainText,
|
||||
};
|
||||
|
||||
let parsed = fff_core::grep::parse_grep_query(query_str);
|
||||
let is_ai = picker.mode().is_ai();
|
||||
let parsed = if is_ai {
|
||||
fff_core::QueryParser::new(fff_query_parser::AiGrepConfig).parse(query_str)
|
||||
} else {
|
||||
fff_core::grep::parse_grep_query(query_str)
|
||||
};
|
||||
|
||||
let options = fff_core::GrepSearchOptions {
|
||||
max_file_size: opts.max_file_size.unwrap_or(10 * 1024 * 1024),
|
||||
@@ -327,9 +344,13 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
page_limit: opts.page_limit.unwrap_or(50),
|
||||
mode,
|
||||
time_budget_ms: opts.time_budget_ms.unwrap_or(0),
|
||||
before_context: opts.before_context.unwrap_or(0),
|
||||
after_context: opts.after_context.unwrap_or(0),
|
||||
classify_definitions: opts.classify_definitions.unwrap_or(false),
|
||||
};
|
||||
|
||||
let result = fff_core::grep::grep_search(picker.get_files(), query_str, parsed, &options);
|
||||
let result =
|
||||
fff_core::grep::grep_search(picker.get_files(), query_str, parsed.as_ref(), &options);
|
||||
|
||||
let json_result = ffi_types::GrepResultJson::from_grep_result(&result);
|
||||
match serde_json::to_string(&json_result) {
|
||||
@@ -338,6 +359,91 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform multi-pattern OR search (Aho-Corasick) across indexed files.
|
||||
///
|
||||
/// Searches for lines matching ANY of the provided patterns using
|
||||
/// SIMD-accelerated multi-needle matching. Faster than regex alternation
|
||||
/// for literal text searches.
|
||||
///
|
||||
/// # Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `opts_json` must be a valid null-terminated UTF-8 string containing
|
||||
/// JSON with a `patterns` array and optional search options.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_multi_grep(
|
||||
fff_handle: *mut c_void,
|
||||
opts_json: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let opts_str = match unsafe { cstr_to_str(opts_json) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Options JSON is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let opts: MultiGrepOptionsJson = match serde_json::from_str(opts_str) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return FffResult::err(&format!("Failed to parse multi-grep options: {}", e)),
|
||||
};
|
||||
|
||||
if opts.patterns.is_empty() {
|
||||
return FffResult::err("patterns array must not be empty");
|
||||
}
|
||||
|
||||
let picker_guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized. Call fff_create first."),
|
||||
};
|
||||
|
||||
let is_ai = picker.mode().is_ai();
|
||||
|
||||
// Parse constraints from the optional string (e.g. "*.rs /src/")
|
||||
let parsed_constraints = opts.constraints.as_deref().and_then(|c| {
|
||||
if is_ai {
|
||||
fff_core::QueryParser::new(fff_query_parser::AiGrepConfig).parse(c)
|
||||
} else {
|
||||
fff_core::grep::parse_grep_query(c)
|
||||
}
|
||||
});
|
||||
|
||||
let constraint_refs: &[fff_core::Constraint<'_>] = match &parsed_constraints {
|
||||
Some(q) => &q.constraints,
|
||||
None => &[],
|
||||
};
|
||||
|
||||
let pattern_refs: Vec<&str> = opts.patterns.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let options = fff_core::GrepSearchOptions {
|
||||
max_file_size: opts.max_file_size.unwrap_or(10 * 1024 * 1024),
|
||||
max_matches_per_file: opts.max_matches_per_file.unwrap_or(0),
|
||||
smart_case: opts.smart_case.unwrap_or(true),
|
||||
file_offset: opts.file_offset.unwrap_or(0),
|
||||
page_limit: opts.page_limit.unwrap_or(50),
|
||||
mode: fff_core::GrepMode::PlainText, // ignored by multi_grep_search
|
||||
time_budget_ms: opts.time_budget_ms.unwrap_or(0),
|
||||
before_context: opts.before_context.unwrap_or(0),
|
||||
after_context: opts.after_context.unwrap_or(0),
|
||||
classify_definitions: opts.classify_definitions.unwrap_or(false),
|
||||
};
|
||||
|
||||
let result =
|
||||
fff_core::multi_grep_search(picker.get_files(), &pattern_refs, constraint_refs, &options);
|
||||
|
||||
let json_result = ffi_types::GrepResultJson::from_grep_result(&result);
|
||||
match serde_json::to_string(&json_result) {
|
||||
Ok(json) => FffResult::ok_data(&json),
|
||||
Err(e) => FffResult::err(&format!("Failed to serialize multi-grep results: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a rescan of the file index.
|
||||
///
|
||||
/// # Safety
|
||||
@@ -497,13 +603,14 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
// Stop existing picker, preserving warmup setting
|
||||
let warmup = if let Some(mut picker) = guard.take() {
|
||||
// Stop existing picker, preserving settings
|
||||
let (warmup, mode) = if let Some(mut picker) = guard.take() {
|
||||
let warmup = picker.warmup_mmap_cache();
|
||||
let mode = picker.mode();
|
||||
picker.stop_background_monitor();
|
||||
warmup
|
||||
(warmup, mode)
|
||||
} else {
|
||||
false
|
||||
(false, FFFMode::default())
|
||||
};
|
||||
|
||||
// Drop the write lock before calling new_with_shared_state,
|
||||
@@ -514,6 +621,7 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
match FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
warmup,
|
||||
mode,
|
||||
Arc::clone(&inst.picker),
|
||||
Arc::clone(&inst.frecency),
|
||||
) {
|
||||
@@ -522,67 +630,6 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
}
|
||||
}
|
||||
|
||||
/// Track file access for frecency scoring.
|
||||
///
|
||||
/// # Safety
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `file_path` must be a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_track_access(
|
||||
fff_handle: *mut c_void,
|
||||
file_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let path_str = match unsafe { cstr_to_str(file_path) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("File path is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let file_path = PathBuf::from(&path_str);
|
||||
|
||||
// Track in frecency DB
|
||||
let frecency_guard = match inst.frecency.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
|
||||
};
|
||||
|
||||
let frecency = match frecency_guard.as_ref() {
|
||||
Some(f) => f,
|
||||
None => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
if let Err(e) = frecency.track_access(&file_path) {
|
||||
return FffResult::err(&format!("Failed to track access: {}", e));
|
||||
}
|
||||
drop(frecency_guard);
|
||||
|
||||
// Update in file picker
|
||||
let mut picker_guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match picker_guard.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
let frecency_guard = match inst.frecency.read() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
if let Some(ref frecency) = *frecency_guard {
|
||||
let _ = picker.update_single_file_frecency(&file_path, frecency);
|
||||
}
|
||||
|
||||
FffResult::ok_data("true")
|
||||
}
|
||||
|
||||
/// Refresh git status cache.
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
@@ -13,6 +13,9 @@ crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
@@ -23,7 +26,7 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
@@ -32,8 +35,10 @@ chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
aho-corasick = "1"
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
@@ -46,10 +51,12 @@ parking_lot = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
toml = "0.8"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { workspace = true }
|
||||
zlob = { workspace = true, optional = true }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
+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,5 +1,5 @@
|
||||
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};
|
||||
@@ -21,6 +21,9 @@ 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(
|
||||
@@ -28,14 +31,16 @@ impl BackgroundWatcher {
|
||||
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, shared_picker, shared_frecency)?;
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
Ok(Self {
|
||||
@@ -48,6 +53,7 @@ impl BackgroundWatcher {
|
||||
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
|
||||
@@ -66,6 +72,7 @@ impl BackgroundWatcher {
|
||||
&git_workdir_for_handler,
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
Err(errors) => {
|
||||
@@ -151,6 +158,7 @@ fn handle_debounced_events(
|
||||
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());
|
||||
@@ -316,6 +324,50 @@ fn handle_debounced_events(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// AI mode: auto-track frecency for all modified/created files.
|
||||
// Uses a 5-minute cooldown per file to prevent score inflation from rapid
|
||||
// burst edits (AI agents often edit the same file many times in minutes).
|
||||
// This runs after apply_changes so the picker write lock is released.
|
||||
if mode.is_ai() && !paths_to_add_or_modify.is_empty() {
|
||||
let mut tracked_count = 0usize;
|
||||
if let Ok(frecency_guard) = shared_frecency.read()
|
||||
&& let Some(ref frecency) = *frecency_guard
|
||||
{
|
||||
for path in &paths_to_add_or_modify {
|
||||
// Skip if this file was tracked less than 5 minutes ago
|
||||
let should_track = match frecency.seconds_since_last_access(path) {
|
||||
Ok(Some(secs)) => secs >= AI_MODE_COOLDOWN_SECS,
|
||||
Ok(None) => true, // Never tracked before
|
||||
Err(_) => true, // DB error, track anyway
|
||||
};
|
||||
if !should_track {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = frecency.track_access(path) {
|
||||
error!("Failed to track frecency for {:?}: {:?}", path, e);
|
||||
} else {
|
||||
tracked_count += 1;
|
||||
}
|
||||
}
|
||||
if tracked_count > 0 {
|
||||
info!("AI mode: tracked frecency for {} files", tracked_count);
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-memory frecency scores for tracked files
|
||||
if tracked_count > 0
|
||||
&& let Ok(mut picker_guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *picker_guard
|
||||
&& let Ok(frecency_guard) = shared_frecency.read()
|
||||
&& let Some(ref frecency) = *frecency_guard
|
||||
{
|
||||
for path in &paths_to_add_or_modify {
|
||||
let _ = picker.update_single_file_frecency(path, frecency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Git status updates require a repository.
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
debug!("No git repo available, skipping git status updates");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,19 @@ use std::sync::{
|
||||
use std::time::SystemTime;
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
#[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]
|
||||
@@ -169,10 +182,14 @@ impl FileItem {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> {
|
||||
self.access_frecency_score = tracker.get_access_score(&self.path);
|
||||
pub fn update_frecency_scores(
|
||||
&mut self,
|
||||
tracker: &FrecencyTracker,
|
||||
mode: FFFMode,
|
||||
) -> Result<(), Error> {
|
||||
self.access_frecency_score = tracker.get_access_score(&self.path, mode);
|
||||
self.modification_frecency_score =
|
||||
tracker.get_modification_score(self.modified, self.git_status);
|
||||
tracker.get_modification_score(self.modified, self.git_status, mode);
|
||||
self.total_frecency_score = self.access_frecency_score + self.modification_frecency_score;
|
||||
|
||||
Ok(())
|
||||
@@ -186,6 +203,8 @@ pub struct FilePicker {
|
||||
scanned_files_count: Arc<AtomicUsize>,
|
||||
background_watcher: Option<BackgroundWatcher>,
|
||||
warmup_mmap_cache: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
mode: FFFMode,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
@@ -211,6 +230,10 @@ impl FilePicker {
|
||||
self.warmup_mmap_cache
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> FFFMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn git_root(&self) -> Option<&Path> {
|
||||
self.sync_data.git_workdir.as_deref()
|
||||
}
|
||||
@@ -232,12 +255,13 @@ impl FilePicker {
|
||||
pub fn new_with_shared_state(
|
||||
base_path: String,
|
||||
warmup_mmap_cache: bool,
|
||||
mode: FFFMode,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
info!(
|
||||
"Initializing FilePicker with base_path: {}, warmup: {}",
|
||||
base_path, warmup_mmap_cache
|
||||
"Initializing FilePicker with base_path: {}, warmup: {}, mode: {:?}",
|
||||
base_path, warmup_mmap_cache, mode
|
||||
);
|
||||
let path = PathBuf::from(&base_path);
|
||||
if !path.exists() {
|
||||
@@ -250,6 +274,7 @@ impl FilePicker {
|
||||
// rather than a stale `false` (the thread hasn't started yet).
|
||||
let scan_signal = Arc::new(AtomicBool::new(true));
|
||||
let synced_files_count = Arc::new(AtomicUsize::new(0));
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let picker = FilePicker {
|
||||
base_path: path.clone(),
|
||||
@@ -258,6 +283,8 @@ impl FilePicker {
|
||||
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
|
||||
@@ -272,8 +299,10 @@ impl FilePicker {
|
||||
Arc::clone(&scan_signal),
|
||||
Arc::clone(&synced_files_count),
|
||||
warmup_mmap_cache,
|
||||
mode,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
cancelled,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -298,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(),
|
||||
@@ -381,6 +416,7 @@ impl FilePicker {
|
||||
"Updating git status",
|
||||
);
|
||||
|
||||
let mode = self.mode;
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
@@ -390,7 +426,7 @@ impl FilePicker {
|
||||
if let Some(file) = self.get_mut_file_by_path(&path) {
|
||||
file.git_status = Some(status);
|
||||
if let Some(ref f) = *frecency {
|
||||
file.update_frecency_scores(f)?;
|
||||
file.update_frecency_scores(f, mode)?;
|
||||
}
|
||||
} else {
|
||||
error!(?path, "Couldn't update the git status for path");
|
||||
@@ -449,7 +485,7 @@ impl FilePicker {
|
||||
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
|
||||
&& let Some(file) = self.sync_data.get_file_mut(index)
|
||||
{
|
||||
file.update_frecency_scores(frecency_tracker)?;
|
||||
file.update_frecency_scores(frecency_tracker, self.mode)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -578,6 +614,11 @@ impl FilePicker {
|
||||
.retain_files(|file| !file.path.starts_with(dir_path))
|
||||
}
|
||||
|
||||
/// We use this to prevent any substantial background threads from acquiring the locks
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(&mut self) {
|
||||
if let Some(watcher) = self.background_watcher.take() {
|
||||
watcher.stop();
|
||||
@@ -593,8 +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, shared_frecency);
|
||||
let scan_result = scan_filesystem(
|
||||
&self.base_path,
|
||||
&self.scanned_files_count,
|
||||
shared_frecency,
|
||||
self.mode,
|
||||
);
|
||||
match scan_result {
|
||||
Ok(sync) => {
|
||||
info!(
|
||||
@@ -637,13 +682,16 @@ 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 is already `true` (set by the caller before spawning)
|
||||
@@ -651,8 +699,14 @@ fn spawn_scan_and_watcher(
|
||||
info!("Starting initial file scan");
|
||||
|
||||
let mut git_workdir = None;
|
||||
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency) {
|
||||
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()
|
||||
@@ -672,13 +726,8 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
|
||||
// The aggressive parallel warmup was causing cache thrashing and delaying
|
||||
// initial searches. Now it runs async and doesn't block.
|
||||
//
|
||||
// We warmup under a read lock on the picker's actual files so that
|
||||
// the OnceLock<Mmap> instances are populated in-place — no clone needed.
|
||||
// Read locks allow concurrent readers so this doesn't block searches.
|
||||
if warmup_mmap_cache
|
||||
&& !cancelled.load(Ordering::Acquire)
|
||||
&& let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
@@ -691,15 +740,31 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
|
||||
// Don't create a watcher if this picker instance was already replaced
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, skipping background watcher creation");
|
||||
return;
|
||||
}
|
||||
|
||||
match BackgroundWatcher::new(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
mode,
|
||||
) {
|
||||
Ok(watcher) => {
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
// Final cancellation check: if the picker was replaced between
|
||||
// watcher creation and this write, drop the watcher instead of
|
||||
// storing it in the wrong picker.
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, dropping orphaned watcher");
|
||||
drop(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.background_watcher = Some(watcher);
|
||||
@@ -733,11 +798,10 @@ fn warmup_mmaps(files: &[FileItem]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(mmap) = file.get_mmap() {
|
||||
// Read the first byte to trigger the initial page fault, which
|
||||
// causes the kernel to start readahead for subsequent pages.
|
||||
// This is cheaper than madvise and portable across all platforms.
|
||||
let _ = std::hint::black_box(mmap.first());
|
||||
if let Some(content) = file.get_mmap() {
|
||||
// Read the first byte to trigger the initial page fault (mmap)
|
||||
// or ensure the content is cached (Windows buffer).
|
||||
let _ = std::hint::black_box(content.first());
|
||||
|
||||
warmed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -748,6 +812,7 @@ 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;
|
||||
@@ -792,7 +857,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(|| {
|
||||
@@ -837,6 +902,7 @@ fn scan_filesystem(
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
|
||||
files
|
||||
.par_iter_mut()
|
||||
.try_for_each(|file| -> Result<(), Error> {
|
||||
@@ -845,7 +911,7 @@ fn scan_filesystem(
|
||||
}
|
||||
|
||||
if let Some(frecency) = frecency.as_ref() {
|
||||
file.update_frecency_scores(frecency)?;
|
||||
file.update_frecency_scores(frecency, mode)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+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 => "clean",
|
||||
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")
|
||||
}
|
||||
|
||||
+990
-223
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ pub mod file_picker;
|
||||
pub mod frecency;
|
||||
pub mod git;
|
||||
pub mod grep;
|
||||
pub mod log;
|
||||
pub mod path_utils;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
@@ -37,6 +38,9 @@ pub use error::{Error, Result};
|
||||
pub use fff_query_parser::{
|
||||
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
|
||||
};
|
||||
pub use file_picker::{FuzzySearchOptions, ScanProgress};
|
||||
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
|
||||
pub use 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())
|
||||
}
|
||||
@@ -65,6 +65,7 @@ impl QueryTracker {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.map_size(10 * 1024 * 1024); // 100 MiB
|
||||
opts.max_dbs(16); // Allow up to 16 databases per environment
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
|
||||
@@ -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,12 +320,14 @@ pub fn match_and_score_files<'a>(
|
||||
0
|
||||
},
|
||||
frecency_boost,
|
||||
git_status_boost,
|
||||
distance_penalty,
|
||||
combo_match_boost,
|
||||
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
|
||||
match_type: match filename_match {
|
||||
Some(filename_match) if filename_match.exact => "exact_filename",
|
||||
Some(_) => "fuzzy_filename",
|
||||
None if path_match.exact => "exact_path",
|
||||
None => "fuzzy_path",
|
||||
},
|
||||
};
|
||||
@@ -363,9 +373,18 @@ pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
let total_frecency_score = file.access_frecency_score as i32
|
||||
+ (file.modification_frecency_score as i32).saturating_mul(4);
|
||||
|
||||
// Give modified/dirty files a boost even in frecency-only mode
|
||||
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
|
||||
total_frecency_score * 15 / 100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let current_file_penalty =
|
||||
calculate_current_file_penalty(file, total_frecency_score, context);
|
||||
let total = total_frecency_score.saturating_add(current_file_penalty);
|
||||
let total = total_frecency_score
|
||||
.saturating_add(git_status_boost)
|
||||
.saturating_add(current_file_penalty);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
@@ -376,6 +395,7 @@ pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
combo_match_boost: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
git_status_boost,
|
||||
exact_match: false,
|
||||
match_type: "frecency",
|
||||
};
|
||||
@@ -485,7 +505,7 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
|
||||
let file_name = path.split('/').last().unwrap_or(path).to_string();
|
||||
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
|
||||
let file = FileItem::new_raw(
|
||||
PathBuf::from(path),
|
||||
path.to_string(),
|
||||
@@ -503,6 +523,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,
|
||||
@@ -568,7 +589,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
|
||||
@@ -617,7 +638,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_no_partial_sort_for_small_results() {
|
||||
// When results.len() <= threshold, should use regular sort
|
||||
let test_data = vec![
|
||||
let test_data = [
|
||||
create_test_file("file1.rs", 100, 1000),
|
||||
create_test_file("file2.rs", 200, 2000),
|
||||
create_test_file("file3.rs", 50, 3000),
|
||||
@@ -666,7 +687,6 @@ mod multi_part_tests {
|
||||
|
||||
// Test with max_typos = 2 (safe for short needles)
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
@@ -698,7 +718,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,18 +1,41 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use memmap2::Mmap;
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
|
||||
/// A single indexed file with metadata, frecency scores, and lazy mmap.
|
||||
/// Cached file contents — mmap on Unix, heap buffer on Windows.
|
||||
///
|
||||
/// The `mmap` field holds the memory-mapped file contents, initialized lazily
|
||||
/// on the first grep access and cached for subsequent searches. The mmap is
|
||||
/// backed by the kernel page cache and automatically reflects file modifications
|
||||
/// — no manual invalidation is needed.
|
||||
/// On Windows, memory-mapped files hold the file handle open and prevent
|
||||
/// editors from saving (writing/replacing) those files. Reading into a
|
||||
/// `Vec<u8>` releases the handle immediately after the read completes.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)] // variants are conditionally used per platform
|
||||
enum FileContent {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
Mmap(memmap2::Mmap),
|
||||
#[cfg(target_os = "windows")]
|
||||
Buffer(Vec<u8>),
|
||||
}
|
||||
|
||||
impl std::ops::Deref for FileContent {
|
||||
type Target = [u8];
|
||||
fn deref(&self) -> &[u8] {
|
||||
match self {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
FileContent::Mmap(m) => m,
|
||||
#[cfg(target_os = "windows")]
|
||||
FileContent::Buffer(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single indexed file with metadata, frecency scores, and lazy content cache.
|
||||
///
|
||||
/// File contents are initialized lazily on the first grep access and cached for
|
||||
/// subsequent searches. On Unix, uses mmap backed by the kernel page cache. On
|
||||
/// Windows, reads into a heap buffer to avoid holding file handles open.
|
||||
///
|
||||
/// Thread-safety: `OnceLock` provides lock-free reads after initialization.
|
||||
/// Each file is only searched by one rayon worker at a time via `par_iter`.
|
||||
@@ -30,10 +53,9 @@ pub struct FileItem {
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: Option<git2::Status>,
|
||||
pub is_binary: bool,
|
||||
/// Lazily-initialized memory-mapped file contents for grep.
|
||||
/// Lazily-initialized file contents for grep.
|
||||
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
|
||||
/// Automatically reflects file changes via the kernel page cache.
|
||||
mmap: OnceLock<Mmap>,
|
||||
content: OnceLock<FileContent>,
|
||||
}
|
||||
|
||||
impl Clone for FileItem {
|
||||
@@ -51,8 +73,8 @@ impl Clone for FileItem {
|
||||
total_frecency_score: self.total_frecency_score,
|
||||
git_status: self.git_status,
|
||||
is_binary: self.is_binary,
|
||||
// Don't clone the mmap — the clone lazily re-creates it on demand
|
||||
mmap: OnceLock::new(),
|
||||
// Don't clone the content — the clone lazily re-creates it on demand
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,46 +105,65 @@ impl FileItem {
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
is_binary,
|
||||
mmap: OnceLock::new(),
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
|
||||
/// Invalidate the cached content so the next `get_content()` call creates a fresh one.
|
||||
///
|
||||
/// Call this when the background watcher detects that the file has been modified.
|
||||
/// While the kernel page cache reflects content changes automatically, a file
|
||||
/// that is truncated (made smaller) while mapped can cause SIGBUS if the search
|
||||
/// accesses pages beyond the new file size. Invalidating the mmap ensures a
|
||||
/// fresh mapping with the correct size is created on the next access.
|
||||
/// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
|
||||
/// the stale buffer simply won't reflect the new contents. In both cases,
|
||||
/// invalidating ensures a fresh read on the next access.
|
||||
pub fn invalidate_mmap(&mut self) {
|
||||
self.mmap = OnceLock::new();
|
||||
self.content = OnceLock::new();
|
||||
}
|
||||
|
||||
/// Get the cached mmap or lazily create it. Returns `None` if the file
|
||||
/// is too large, empty, or can't be opened/mapped.
|
||||
/// Get the cached file contents or lazily load them. Returns `None` if the
|
||||
/// file is too large, empty, or can't be opened.
|
||||
///
|
||||
/// After the first call, this is lock-free (just an atomic load + pointer deref).
|
||||
/// The mmap is backed by the kernel page cache and automatically reflects
|
||||
/// file modifications — no manual invalidation is needed.
|
||||
/// On Unix, uses mmap backed by the kernel page cache. On Windows, reads into
|
||||
/// a heap buffer so the file handle is released immediately.
|
||||
#[inline]
|
||||
pub fn get_mmap(&self) -> Option<&Mmap> {
|
||||
if let Some(mmap) = self.mmap.get() {
|
||||
return Some(mmap);
|
||||
pub fn get_content(&self) -> Option<&[u8]> {
|
||||
if let Some(content) = self.content.get() {
|
||||
return Some(content);
|
||||
}
|
||||
|
||||
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&self.path).ok()?;
|
||||
let content = load_file_content(&self.path)?;
|
||||
|
||||
// If another thread raced us, OnceLock discards ours and returns theirs.
|
||||
Some(self.content.get_or_init(|| content))
|
||||
}
|
||||
|
||||
/// Backward-compatible alias for `get_content`.
|
||||
#[inline]
|
||||
pub fn get_mmap(&self) -> Option<&[u8]> {
|
||||
self.get_content()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load file contents: mmap on Unix, heap buffer on Windows.
|
||||
fn load_file_content(path: &Path) -> Option<FileContent> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
// SAFETY: The mmap is backed by the kernel page cache and automatically
|
||||
// reflects file modifications. The only risk is SIGBUS if the file is
|
||||
// truncated while mapped
|
||||
let mmap = unsafe { Mmap::map(&file) }.ok()?;
|
||||
// truncated while mapped.
|
||||
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
|
||||
Some(FileContent::Mmap(mmap))
|
||||
}
|
||||
|
||||
// If another thread raced us, OnceLock discards our mmap and returns theirs.
|
||||
// This is fine — the duplicate mmap is just dropped.
|
||||
Some(self.mmap.get_or_init(|| mmap))
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let data = std::fs::read(path).ok()?;
|
||||
Some(FileContent::Buffer(data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +196,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,
|
||||
|
||||
@@ -25,6 +25,9 @@ fn plain_opts() -> GrepSearchOptions {
|
||||
page_limit: 200,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +41,9 @@ fn regex_opts() -> GrepSearchOptions {
|
||||
page_limit: 200,
|
||||
mode: GrepMode::Regex,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +57,9 @@ fn fuzzy_opts() -> GrepSearchOptions {
|
||||
page_limit: 200,
|
||||
mode: GrepMode::Fuzzy,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +525,27 @@ fn regex_anchors() {
|
||||
assert_eq!(result.matches[0].line_number, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_anchors_multiword() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let files = vec![create_file(
|
||||
tmp.path(),
|
||||
"test.c",
|
||||
"int ff_function(void);\nstatic int ff_other(void);\nint main(void);\nint ff_another(void);\n",
|
||||
)];
|
||||
|
||||
// ^int ff_ should match lines starting with "int ff_"
|
||||
let result = grep_search(&files, "^int ff_", None, ®ex_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
2,
|
||||
"should match 2 lines starting with 'int ff_'"
|
||||
);
|
||||
assert!(result.matches[0].line_content.contains("ff_function"));
|
||||
assert!(result.matches[1].line_content.contains("ff_another"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_highlight_offsets_variable_length() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -659,7 +689,7 @@ fn grep_with_extension_constraint() {
|
||||
];
|
||||
|
||||
let parsed = parse_grep_query("use std *.rs");
|
||||
let result = grep_search(&files, "use std *.rs", parsed, &plain_opts());
|
||||
let result = grep_search(&files, "use std *.rs", parsed.as_ref(), &plain_opts());
|
||||
|
||||
// Should only search .rs files
|
||||
for file in &result.files {
|
||||
@@ -708,7 +738,7 @@ fn grep_backslash_escapes_extension_filter() {
|
||||
|
||||
// Without escape: "*.rs" is an extension filter, so only .rs files are searched
|
||||
let parsed = parse_grep_query("pattern *.rs");
|
||||
let result_filter = grep_search(&files, "pattern *.rs", parsed, &plain_opts());
|
||||
let result_filter = grep_search(&files, "pattern *.rs", parsed.as_ref(), &plain_opts());
|
||||
assert_eq!(
|
||||
result_filter.files.len(),
|
||||
1,
|
||||
@@ -717,7 +747,7 @@ fn grep_backslash_escapes_extension_filter() {
|
||||
|
||||
// With escape: "\*.rs" is literal text, both files are searched
|
||||
let parsed_escaped = parse_grep_query("\\*.rs");
|
||||
let result_literal = grep_search(&files, "\\*.rs", parsed_escaped, &plain_opts());
|
||||
let result_literal = grep_search(&files, "\\*.rs", parsed_escaped.as_ref(), &plain_opts());
|
||||
assert_eq!(
|
||||
result_literal.matches.len(),
|
||||
2,
|
||||
@@ -767,7 +797,7 @@ fn grep_with_path_constraint() {
|
||||
];
|
||||
|
||||
let parsed = parse_grep_query("target_text /src/");
|
||||
let result = grep_search(&files, "target_text /src/", parsed, &plain_opts());
|
||||
let result = grep_search(&files, "target_text /src/", parsed.as_ref(), &plain_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert!(result.files[0].relative_path.starts_with("src/"));
|
||||
@@ -786,7 +816,7 @@ fn grep_with_negated_extension_constraint() {
|
||||
|
||||
let query = "target_text !*.rs";
|
||||
let parsed = parse_grep_query(query);
|
||||
let result = grep_search(&files, query, parsed, &plain_opts());
|
||||
let result = grep_search(&files, query, parsed.as_ref(), &plain_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -812,7 +842,7 @@ fn grep_with_negated_path_constraint() {
|
||||
|
||||
let query = "target_text !/src/";
|
||||
let parsed = parse_grep_query(query);
|
||||
let result = grep_search(&files, query, parsed, &plain_opts());
|
||||
let result = grep_search(&files, query, parsed.as_ref(), &plain_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -838,7 +868,7 @@ fn grep_with_negated_text_constraint() {
|
||||
|
||||
let query = "target_text !test";
|
||||
let parsed = parse_grep_query(query);
|
||||
let result = grep_search(&files, query, parsed, &plain_opts());
|
||||
let result = grep_search(&files, query, parsed.as_ref(), &plain_opts());
|
||||
|
||||
// "tests/helper.rs" contains "test" in path, should be excluded
|
||||
assert_eq!(
|
||||
@@ -1024,7 +1054,7 @@ fn fuzzy_finds_scattered_characters() {
|
||||
let result = grep_search(&files, "mutex", None, &fuzzy_opts());
|
||||
|
||||
assert!(
|
||||
result.matches.len() >= 1,
|
||||
!result.matches.is_empty(),
|
||||
"fuzzy should find 'mutex' in 'mutex_lock'"
|
||||
);
|
||||
assert!(result.matches[0].line_content.contains("mutex_lock"));
|
||||
@@ -1063,7 +1093,7 @@ fn fuzzy_unicode_char_indices() {
|
||||
|
||||
// Should fuzzy match "régulière" (with multi-byte é and è)
|
||||
// This tests that character-to-byte offset conversion works with UTF-8
|
||||
assert!(result.matches.len() >= 1);
|
||||
assert!(!result.matches.is_empty());
|
||||
assert!(result.matches[0].line_content.contains("régulière"));
|
||||
}
|
||||
|
||||
@@ -1088,7 +1118,7 @@ fn fuzzy_with_extension_constraint() {
|
||||
];
|
||||
|
||||
let parsed = parse_grep_query("use std *.rs");
|
||||
let result = grep_search(&files, "use std *.rs", parsed, &fuzzy_opts());
|
||||
let result = grep_search(&files, "use std *.rs", parsed.as_ref(), &fuzzy_opts());
|
||||
|
||||
// Should only search .rs files
|
||||
for file in &result.files {
|
||||
|
||||
@@ -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,294 @@
|
||||
//! FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
//!
|
||||
//! Drop-in replacement for AI code assistant file search tools (Glob/Grep).
|
||||
//! Provides frecency-ranked, fuzzy-matched, git-aware file finding and
|
||||
//! code search via the Model Context Protocol (MCP).
|
||||
//!
|
||||
//! Uses `fff-core` directly (zero FFI overhead) for all search operations.
|
||||
|
||||
mod cursor;
|
||||
mod output;
|
||||
mod server;
|
||||
mod update_check;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use clap::Parser;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::{FFFMode, SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use mimalloc::MiMalloc;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use server::FffServer;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
pub const MCP_INSTRUCTIONS: &str = concat!(
|
||||
"FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n",
|
||||
"\n",
|
||||
"## Which Tool Should I Use?\n",
|
||||
"\n",
|
||||
"- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n",
|
||||
"- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n",
|
||||
"- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n",
|
||||
"\n",
|
||||
"## Core Rules\n",
|
||||
"\n",
|
||||
"### 1. Search BARE IDENTIFIERS only\n",
|
||||
"Grep matches single lines. Search for ONE identifier per query:\n",
|
||||
" + 'InProgressQuote' -> finds definition + all usages\n",
|
||||
" + 'ActorAuth' -> finds enum, struct, all call sites\n",
|
||||
" x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n",
|
||||
" x 'ctx.data::<ActorAuth>' -> code syntax, too specific, 0 results\n",
|
||||
" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n",
|
||||
" x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n",
|
||||
"\n",
|
||||
"### 2. NEVER use regex unless you truly need alternation\n",
|
||||
"Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n",
|
||||
"If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n",
|
||||
"\n",
|
||||
"### 3. Stop searching after 2 greps -- READ the code\n",
|
||||
"After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n",
|
||||
"Do NOT keep grepping with variations. More greps != better understanding.\n",
|
||||
"\n",
|
||||
"### 4. Use multi_grep for multiple identifiers\n",
|
||||
"When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n",
|
||||
" + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n",
|
||||
" x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n",
|
||||
"\n",
|
||||
"## Workflow\n",
|
||||
"\n",
|
||||
"**Have a specific name?** -> grep the bare identifier.\n",
|
||||
"**Need multiple name variants?** -> multi_grep with all variants in one call.\n",
|
||||
"**Exploring a topic / finding files?** -> find_files.\n",
|
||||
"**Got results?** -> Read the top file. Don't grep again.\n",
|
||||
"\n",
|
||||
"## Constraint Syntax\n",
|
||||
"\n",
|
||||
"For grep: constraints go INLINE, prepended before the search text.\n",
|
||||
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
|
||||
"\n",
|
||||
"Constraints MUST match one of these formats:\n",
|
||||
" Extension: '*.rs', '*.{ts,tsx}'\n",
|
||||
" Directory: 'src/', 'quotes/'\n",
|
||||
" Filename: 'schema.rs', 'src/main.rs'\n",
|
||||
" Exclude: '!test/', '!*.spec.ts'\n",
|
||||
"\n",
|
||||
"! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
|
||||
" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n",
|
||||
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
|
||||
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
|
||||
"\n",
|
||||
"Prefer broad constraints:\n",
|
||||
" + '*.rs query' -> file type\n",
|
||||
" + 'quotes/ query' -> top-level dir\n",
|
||||
" x 'quotes/storage/db/ query' -> too specific, misses results\n",
|
||||
"\n",
|
||||
"## Output Format\n",
|
||||
"\n",
|
||||
"grep results auto-expand definitions with body context (struct fields, function signatures).\n",
|
||||
"This often provides enough information WITHOUT a follow-up Read call.\n",
|
||||
"Lines marked with | are definition body context. [def] marks definition files.\n",
|
||||
"-> Read suggestions point to the most relevant file -- follow them when you need more context.\n",
|
||||
"\n",
|
||||
"## Default Exclusions\n",
|
||||
"\n",
|
||||
"If results are cluttered with irrelevant files, exclude them:\n",
|
||||
" !tests/ - exclude tests directory\n",
|
||||
" !*.spec.ts - exclude test files\n",
|
||||
" !generated/ - exclude generated code",
|
||||
);
|
||||
|
||||
/// FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
|
||||
struct Args {
|
||||
/// Base directory to index. Defaults to the current working directory.
|
||||
#[arg(value_name = "PATH")]
|
||||
base_path: Option<String>,
|
||||
|
||||
/// Path to the frecency database.
|
||||
#[arg(long = "frecency-db")]
|
||||
frecency_db_path: Option<String>,
|
||||
|
||||
/// Path to the query history database.
|
||||
#[arg(long = "history-db")]
|
||||
#[allow(dead_code)]
|
||||
history_db_path: Option<String>,
|
||||
|
||||
/// Path to the log file.
|
||||
#[arg(long = "log-file")]
|
||||
log_file: Option<String>,
|
||||
|
||||
/// Log level (e.g. trace, debug, info, warn, error).
|
||||
#[arg(long = "log-level")]
|
||||
log_level: Option<String>,
|
||||
|
||||
/// Disable automatic update checks on startup.
|
||||
#[arg(long = "no-update-check")]
|
||||
no_update_check: bool,
|
||||
}
|
||||
|
||||
/// Resolve default paths for frecency db, history db, and log file.
|
||||
/// Shares Neovim's standard data locations when they exist so the MCP
|
||||
/// server and fff.nvim plugin use the same databases.
|
||||
fn resolve_defaults(args: &mut Args) {
|
||||
let home = dirs_home();
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
|
||||
let nvim_cache_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.cache/nvim", home)
|
||||
};
|
||||
let nvim_data_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.local/share/nvim", home)
|
||||
};
|
||||
|
||||
let use_nvim_paths = std::path::Path::new(&nvim_cache_dir).exists()
|
||||
|| std::path::Path::new(&nvim_data_dir).exists();
|
||||
|
||||
if args.frecency_db_path.is_none() {
|
||||
args.frecency_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_nvim", nvim_cache_dir)
|
||||
} else {
|
||||
format!("{}/.fff/frecency.mdb", home)
|
||||
});
|
||||
}
|
||||
if args.history_db_path.is_none() {
|
||||
args.history_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_queries", nvim_data_dir)
|
||||
} else {
|
||||
format!("{}/.fff/history.mdb", home)
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure parent directories exist for database paths
|
||||
for path in [&args.frecency_db_path, &args.history_db_path]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
if args.log_file.is_none() {
|
||||
args.log_file = Some(if is_windows {
|
||||
format!("{}\\AppData\\Local\\fff_mcp.log", home)
|
||||
} else {
|
||||
format!("{}/.cache/fff_mcp.log", home)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_home() -> String {
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| "/tmp".to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = Args::parse();
|
||||
resolve_defaults(&mut args);
|
||||
|
||||
let log_file = args.log_file.as_deref().unwrap_or("");
|
||||
if let Err(e) = fff_core::log::init_tracing(log_file, args.log_level.as_deref()) {
|
||||
eprintln!("Warning: Failed to init tracing: {}", e);
|
||||
}
|
||||
|
||||
let base_path = args.base_path.unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
if Repository::discover(&base_path).is_err() {
|
||||
tracing::error!("MCP server must be run within a Git repository");
|
||||
return Err(format!("Not a Git repository: {}", base_path).into());
|
||||
}
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
match FrecencyTracker::new(&frecency_db_path, false) {
|
||||
Ok(tracker) => {
|
||||
if let Ok(mut guard) = shared_frecency.write() {
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to init frecency db: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize file picker (spawns background scan + watcher)
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
true, // warmup_mmap_cache
|
||||
FFFMode::Ai,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
.map_err(|e| format!("Failed to init file picker: {}", e))?;
|
||||
|
||||
if !args.no_update_check {
|
||||
update_check::spawn_update_check();
|
||||
}
|
||||
|
||||
// Create and start the MCP server
|
||||
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
|
||||
|
||||
// Wait for initial scan in background — don't block server startup
|
||||
let picker_clone_for_scan = Arc::clone(&shared_picker);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
let is_scanning = picker_clone_for_scan
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|p| p.is_scan_active()))
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_scanning {
|
||||
tracing::info!("Initial scan completed in {:?}", start.elapsed());
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
|
||||
let service = server
|
||||
.serve(stdio())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start MCP server: {}", e))?;
|
||||
|
||||
let picker_for_shutdown = shared_picker.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
if let Ok(mut guard) = picker_for_shutdown.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
std::process::exit(0);
|
||||
});
|
||||
|
||||
service.waiting().await?;
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -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,696 @@
|
||||
//! FFF MCP server — tool definitions and handlers.
|
||||
//!
|
||||
//! Uses the `rmcp` crate's `#[tool_router]` / `#[tool_handler]` macros
|
||||
//! for declarative tool registration. Each tool method directly calls
|
||||
//! `fff-core` APIs (no C FFI overhead).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff_query_parser::AiGrepConfig;
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::*;
|
||||
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
|
||||
/// Strip common delimiters and lowercase for fuzzy fallback queries.
|
||||
fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if !matches!(c, ':' | '-' | '_') {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute grep search options from output mode and context settings.
|
||||
fn make_grep_options(
|
||||
output_mode: OutputMode,
|
||||
mode: GrepMode,
|
||||
file_offset: usize,
|
||||
context: Option<usize>,
|
||||
) -> (GrepSearchOptions, bool) {
|
||||
let is_usage = output_mode == OutputMode::Usage;
|
||||
let matches_per_file = match output_mode {
|
||||
OutputMode::FilesWithMatches => 1,
|
||||
_ if is_usage => 8,
|
||||
_ => 10,
|
||||
};
|
||||
let ctx_lines = if is_usage {
|
||||
context.unwrap_or(1)
|
||||
} else {
|
||||
context.unwrap_or(0)
|
||||
};
|
||||
let auto_expand = !is_usage && ctx_lines == 0;
|
||||
let after_ctx = if auto_expand { 8 } else { ctx_lines };
|
||||
|
||||
(
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: matches_per_file,
|
||||
smart_case: true,
|
||||
file_offset,
|
||||
page_limit: 50,
|
||||
mode,
|
||||
time_budget_ms: 0,
|
||||
before_context: ctx_lines,
|
||||
after_context: after_ctx,
|
||||
classify_definitions: true,
|
||||
},
|
||||
auto_expand,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct FindFilesParams {
|
||||
/// Fuzzy search query. Supports path prefixes and glob constraints.
|
||||
pub query: String,
|
||||
/// Max results (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GrepParams {
|
||||
/// Search text or regex query with optional constraint prefixes.
|
||||
/// Matches within single lines only — use ONE specific term, not multiple words.
|
||||
pub query: String,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_patterns<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct PatternsVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for PatternsVisitor {
|
||||
type Value = Vec<String>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string, an array of strings, or a stringified JSON array")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
// Try to parse as JSON array first
|
||||
if v.starts_with('[')
|
||||
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(v)
|
||||
{
|
||||
return Ok(parsed);
|
||||
}
|
||||
Ok(vec![v.to_string()])
|
||||
}
|
||||
|
||||
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
|
||||
if v.starts_with('[')
|
||||
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(&v)
|
||||
{
|
||||
return Ok(parsed);
|
||||
}
|
||||
Ok(vec![v])
|
||||
}
|
||||
|
||||
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
|
||||
let mut values = Vec::new();
|
||||
while let Some(value) = seq.next_element::<String>()? {
|
||||
values.push(value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(PatternsVisitor)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct MultiGrepParams {
|
||||
/// Patterns to match (OR logic). Include all naming conventions: snake_case, PascalCase, camelCase.
|
||||
#[serde(deserialize_with = "deserialize_patterns")]
|
||||
pub patterns: Vec<String>,
|
||||
/// File constraints (e.g. '*.{ts,tsx} !test/'). ALWAYS provide when possible.
|
||||
pub constraints: Option<String>,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
/// Context lines before/after each match.
|
||||
pub context: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FffServer {
|
||||
picker: SharedPicker,
|
||||
#[allow(dead_code)]
|
||||
frecency: SharedFrecency,
|
||||
cursor_store: Arc<Mutex<CursorStore>>,
|
||||
update_notice_sent: Arc<AtomicBool>,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
|
||||
Self {
|
||||
picker,
|
||||
frecency,
|
||||
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
|
||||
update_notice_sent: Arc::new(AtomicBool::new(false)),
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the initial file scan to complete.
|
||||
#[allow(dead_code)]
|
||||
pub fn wait_for_scan(&self) {
|
||||
loop {
|
||||
let guard = self.picker.read().ok();
|
||||
let is_scanning = guard
|
||||
.as_ref()
|
||||
.and_then(|g| g.as_ref())
|
||||
.map(|p| p.is_scan_active())
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_scanning {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock the cursor store, returning an MCP error on poisoned mutex.
|
||||
fn lock_cursors(&self) -> Result<std::sync::MutexGuard<'_, CursorStore>, ErrorData> {
|
||||
self.cursor_store.lock().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire cursor store lock: {e}"), None)
|
||||
})
|
||||
}
|
||||
|
||||
/// If an update notice is available and hasn't been sent yet, append it
|
||||
/// to the tool result. Called once per server lifetime (first tool call).
|
||||
fn maybe_append_update_notice(&self, result: &mut CallToolResult) {
|
||||
if self.update_notice_sent.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let notice = crate::update_check::get_update_notice();
|
||||
if notice.is_empty() {
|
||||
// Reset so the next call can try again (check may still be in flight)
|
||||
self.update_notice_sent.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
result.content.push(Content::text(notice));
|
||||
}
|
||||
|
||||
/// Perform grep with auto-retry logic.
|
||||
///
|
||||
/// Acquires the picker read-lock once and holds it for the entire
|
||||
/// operation, so `GrepResult` references are used directly — no cloning.
|
||||
/// Always uses AI query parsing since this is an MCP server for AI agents.
|
||||
fn perform_grep(
|
||||
&self,
|
||||
query: &str,
|
||||
mode: GrepMode,
|
||||
max_results: usize,
|
||||
cursor_id: Option<&str>,
|
||||
output_mode: OutputMode,
|
||||
context: Option<usize>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let file_offset = cursor_id
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let (options, auto_expand) = make_grep_options(output_mode, mode, file_offset, context);
|
||||
let ctx_lines = options.before_context;
|
||||
|
||||
// Acquire picker lock once for the entire operation.
|
||||
let guard = self.picker.read().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
|
||||
})?;
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let parsed = parser.parse(query);
|
||||
let result = grep::grep_search(files, 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 = cleanup_fuzzy_query(query);
|
||||
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
|
||||
let fuzzy_parsed = parser.parse(&fuzzy_query);
|
||||
let fuzzy_result =
|
||||
grep::grep_search(files, &fuzzy_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"),
|
||||
)]));
|
||||
}
|
||||
|
||||
// File path fallback: if query looks like a path, suggest the matching file
|
||||
if query.contains('/') {
|
||||
let file_parser = QueryParser::default();
|
||||
let file_query = file_parser.parse(query);
|
||||
let file_opts = FuzzySearchOptions {
|
||||
max_threads: 0,
|
||||
current_file: None,
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
};
|
||||
let file_result = FilePicker::fuzzy_search(files, query, file_query, file_opts);
|
||||
if let (Some(top), Some(score)) =
|
||||
(file_result.items.first(), file_result.scores.first())
|
||||
{
|
||||
// Only suggest when the match is strong enough.
|
||||
let query_len = query.len() as i32;
|
||||
if score.base_score > query_len * 10 {
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 content matches. But there is a relevant file path: {}",
|
||||
top.relative_path
|
||||
))]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
if result.matches.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &result.matches,
|
||||
files: &result.files,
|
||||
total_matched: result.matches.len(),
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: result.regex_fallback_error.as_deref(),
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(text)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl FffServer {
|
||||
/// Fuzzy file search by name. Searches FILE NAMES, not file contents.
|
||||
/// Use it when you need to find a file, not a definition.
|
||||
/// Use grep instead for searching code content (definitions, usage patterns).
|
||||
/// Supports fuzzy matching, path prefixes ('shc/'), and glob constraints.
|
||||
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
|
||||
#[tool(
|
||||
name = "find_files",
|
||||
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
|
||||
)]
|
||||
fn find_files(
|
||||
&self,
|
||||
Parameters(params): Parameters<FindFilesParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let query = ¶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,6 +7,10 @@ edition = "2024"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
@@ -61,9 +65,6 @@ once_cell = "1.20.2"
|
||||
pathdiff = "0.2.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
|
||||
use std::path::PathBuf;
|
||||
@@ -29,6 +29,7 @@ fn init_file_picker_internal(
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(shared_picker),
|
||||
Arc::clone(shared_frecency),
|
||||
)
|
||||
@@ -350,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;
|
||||
|
||||
@@ -71,11 +71,14 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
page_limit: 100, // Get plenty of results
|
||||
mode: GrepMode::Fuzzy,
|
||||
time_budget_ms: 0, // No time limit — search all files
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let result = grep_search(files, query, parsed.as_ref(), &options);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
eprintln!("══════════════════════════════════════════════════════════════");
|
||||
|
||||
@@ -10,7 +10,7 @@ use fff_core::FileItem;
|
||||
/// Usage:
|
||||
/// cargo build --release --bin grep_profiler
|
||||
/// ./target/release/grep_profiler [--path /path/to/repo]
|
||||
use fff_core::grep::{GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use fff_core::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -119,6 +119,10 @@ struct GrepBench<'a> {
|
||||
|
||||
impl<'a> GrepBench<'a> {
|
||||
fn new(files: &'a [FileItem]) -> Self {
|
||||
Self::with_mode(files, GrepMode::PlainText)
|
||||
}
|
||||
|
||||
fn with_mode(files: &'a [FileItem], mode: GrepMode) -> Self {
|
||||
Self {
|
||||
files,
|
||||
options: GrepSearchOptions {
|
||||
@@ -127,8 +131,11 @@ impl<'a> GrepBench<'a> {
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
mode,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -137,7 +144,7 @@ impl<'a> GrepBench<'a> {
|
||||
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(self.files, query, parsed, &self.options);
|
||||
let result = grep_search(self.files, query, parsed.as_ref(), &self.options);
|
||||
let elapsed = start.elapsed();
|
||||
(elapsed, result.matches.len(), result.total_files_searched)
|
||||
}
|
||||
@@ -220,7 +227,7 @@ fn main() {
|
||||
eprintln!("Repository: {:?}", canonical);
|
||||
|
||||
// Direct file loading (no background thread)
|
||||
eprintln!("\n[1/5] Loading files...");
|
||||
eprintln!("\n[1/7] Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let load_time = load_start.elapsed();
|
||||
@@ -236,7 +243,7 @@ fn main() {
|
||||
|
||||
let bench = GrepBench::new(&files);
|
||||
|
||||
eprintln!("[2/5] Cold cache benchmarks (first search, mmap not yet loaded)");
|
||||
eprintln!("[2/7] Cold cache benchmarks (first search, mmap not yet loaded)");
|
||||
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
|
||||
print_header();
|
||||
|
||||
@@ -259,7 +266,7 @@ fn main() {
|
||||
print_row(name, &stats, matches, files_searched, 1);
|
||||
}
|
||||
|
||||
eprintln!("\n[3/5] Warm cache benchmarks (mmap cache populated)");
|
||||
eprintln!("\n[3/7] Warm cache benchmarks (plain text, mmap cache populated)");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
@@ -293,7 +300,84 @@ fn main() {
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
eprintln!("\n[4/5] Incremental typing simulation");
|
||||
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
|
||||
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
let fuzzy_bench = GrepBench::with_mode(&files, GrepMode::Fuzzy);
|
||||
|
||||
let fuzzy_queries: Vec<(&str, &str, usize)> = vec![
|
||||
("fuzzy_exact", "mutex_lock", 15),
|
||||
("fuzzy_typo", "mutx_lock", 15),
|
||||
("fuzzy_camel", "InodeOps", 15),
|
||||
("fuzzy_abbrev", "sched_rt", 15),
|
||||
("fuzzy_short", "kfr", 15),
|
||||
("fuzzy_common", "return", 10),
|
||||
("fuzzy_define", "MODULE_LICENSE", 15),
|
||||
("fuzzy_struct", "file_operations", 15),
|
||||
("fuzzy_long", "static_int_init", 15),
|
||||
("fuzzy_path", "printk *.c", 15),
|
||||
];
|
||||
|
||||
// Warmup
|
||||
for (_, query, _) in &fuzzy_queries {
|
||||
for _ in 0..3 {
|
||||
fuzzy_bench.run_once(query);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, query, iters) in &fuzzy_queries {
|
||||
let (stats, matches, files_searched) = fuzzy_bench.bench_query(query, *iters);
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy incremental typing ────────────────────────────────────────
|
||||
eprintln!("\n[5/7] Fuzzy incremental typing simulation");
|
||||
eprintln!(" Simulates user typing character by character (fuzzy mode).\n");
|
||||
|
||||
let fuzzy_typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
vec![
|
||||
"m",
|
||||
"mu",
|
||||
"mut",
|
||||
"mute",
|
||||
"mutex",
|
||||
"mutex_",
|
||||
"mutex_l",
|
||||
"mutex_lo",
|
||||
"mutex_loc",
|
||||
"mutex_lock",
|
||||
],
|
||||
),
|
||||
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
|
||||
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
|
||||
];
|
||||
|
||||
for (name, sequence) in &fuzzy_typing_sequences {
|
||||
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
"Query", "Latency", "Match", "Files"
|
||||
);
|
||||
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
|
||||
|
||||
for prefix in sequence {
|
||||
let (elapsed, matches, files_searched) = fuzzy_bench.run_once(prefix);
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
format!("\"{}\"", prefix),
|
||||
fmt_dur(elapsed),
|
||||
matches,
|
||||
files_searched,
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[6/7] Incremental typing simulation (plain text)");
|
||||
eprintln!(" Simulates user typing character by character.\n");
|
||||
|
||||
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
@@ -338,7 +422,7 @@ fn main() {
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[5/5] Pagination benchmark");
|
||||
eprintln!("[7/7] Pagination benchmark");
|
||||
eprintln!(" Testing page_offset performance for common query.\n");
|
||||
|
||||
let pagination_query = "return";
|
||||
@@ -363,9 +447,12 @@ fn main() {
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(&files, pagination_query, parsed, &opts);
|
||||
let result = grep_search(&files, pagination_query, parsed.as_ref(), &opts);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use fff_core::FFFQuery;
|
||||
use fff_core::FileItem;
|
||||
/// FFF vs ripgrep comparison benchmark
|
||||
///
|
||||
@@ -204,9 +205,36 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
page_limit: usize::MAX,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let result = grep_search(files, 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)
|
||||
}
|
||||
@@ -222,9 +250,12 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let result = grep_search(files, query, parsed.as_ref(), &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::sync::{Arc, RwLock};
|
||||
@@ -188,6 +188,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::{
|
||||
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
@@ -81,6 +81,7 @@ fn main() {
|
||||
FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::file_picker::{FFFMode, FilePicker};
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
@@ -87,6 +87,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff_core::{
|
||||
FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -48,6 +50,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::default(),
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
+44
-15
@@ -4,8 +4,8 @@ use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{
|
||||
DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency,
|
||||
SharedPicker, SharedQueryTracker,
|
||||
DbHealthChecker, Error, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser,
|
||||
SharedFrecency, SharedPicker, SharedQueryTracker,
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use mlua::prelude::*;
|
||||
@@ -43,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()
|
||||
@@ -91,6 +95,7 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)
|
||||
@@ -100,20 +105,28 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
// Stop existing picker
|
||||
// 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(mut picker) = guard.take() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
picker.cancel();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Don't take() here — leave the old picker in place so searches
|
||||
// still work until new_with_shared_state replaces it atomically.
|
||||
}
|
||||
|
||||
// Create new picker backed by the same shared state
|
||||
// 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),
|
||||
)?;
|
||||
@@ -134,6 +147,12 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
|
||||
&& picker.base_path() == canonical_path
|
||||
{
|
||||
return Ok(()); // same dir
|
||||
}
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
@@ -297,9 +316,12 @@ pub fn live_grep(
|
||||
page_limit: page_size.unwrap_or(50),
|
||||
mode,
|
||||
time_budget_ms: time_budget_ms.unwrap_or(0),
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed, &options);
|
||||
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed.as_ref(), &options);
|
||||
|
||||
lua_types::GrepResultLua::from(result).into_lua(lua)
|
||||
}
|
||||
@@ -564,21 +586,28 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
// Extract the scan signal Arc WITHOUT holding the read lock, so the
|
||||
// scan thread can acquire the write lock to store its results.
|
||||
// Holding a read lock while polling would deadlock: the scan thread
|
||||
// needs a write lock to finish, but can't acquire it while we hold the read lock.
|
||||
let scan_signal = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
picker.scan_signal()
|
||||
}; // read lock released here
|
||||
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() {
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
|
||||
+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};
|
||||
|
||||
@@ -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,5 +1,46 @@
|
||||
use crate::constraints::Constraint;
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
use crate::glob_detect::has_wildcards;
|
||||
|
||||
/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
|
||||
///
|
||||
/// A token is a filename/path if ALL of:
|
||||
/// - Does NOT end with `/` (that's a directory/PathSegment)
|
||||
/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
|
||||
/// - Last component (after final `/`) contains `.` with a valid-looking extension
|
||||
/// (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 {
|
||||
@@ -32,6 +73,13 @@ pub trait ParserConfig {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse location suffixes (e.g., file:12, file:12:4)
|
||||
/// Disabled for grep modes where colon-number patterns like localhost:8080
|
||||
/// are search text, not file locations.
|
||||
fn enable_location(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Determine whether a token should be treated as a glob constraint.
|
||||
///
|
||||
/// The default implementation delegates to `zlob::has_wildcards` with
|
||||
@@ -40,7 +88,7 @@ pub trait ParserConfig {
|
||||
/// Override this in configs where some wildcard characters are common
|
||||
/// in search text (e.g. grep mode where `?` and `[` appear in code).
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
has_wildcards(token, ZlobFlags::RECOMMENDED)
|
||||
has_wildcards(token)
|
||||
}
|
||||
|
||||
/// Custom constraint parsers for picker-specific needs
|
||||
@@ -54,7 +102,16 @@ pub trait ParserConfig {
|
||||
pub struct FilePickerConfig;
|
||||
|
||||
impl ParserConfig for FilePickerConfig {
|
||||
// All defaults enabled
|
||||
/// Detect bare filenames (`score.rs`) and path-prefixed filenames (`src/main.rs`)
|
||||
/// as `FilePath` constraints so that multi-token queries like `score.rs file_picker`
|
||||
/// filter by filename first, then fuzzy-match the remaining text against the path.
|
||||
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
|
||||
if is_filename_constraint_token(token) {
|
||||
Some(Constraint::FilePath(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for full-text search (grep) - file constraints enabled for
|
||||
@@ -76,6 +133,10 @@ impl ParserConfig for GrepConfig {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_location(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Only recognise globs that are clearly directory/path oriented.
|
||||
///
|
||||
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
|
||||
@@ -87,7 +148,7 @@ impl ParserConfig for GrepConfig {
|
||||
/// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// Must contain at least one glob wildcard character
|
||||
if !has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -107,3 +168,59 @@ impl ParserConfig for GrepConfig {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
|
||||
/// automatic file-path constraint detection.
|
||||
///
|
||||
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
|
||||
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
|
||||
/// so the search is scoped to matching files. The caller validates the
|
||||
/// constraint against the index and drops it if no files match (fallback).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AiGrepConfig;
|
||||
|
||||
impl ParserConfig for AiGrepConfig {
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn enable_git_status(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_location(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// First check GrepConfig's strict rules (path globs, brace expansion)
|
||||
if GrepConfig.is_glob_pattern(token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
|
||||
// Recognise tokens that start AND end with `*` with non-empty text
|
||||
// between them as glob constraints. Bare `*` or `**` are excluded.
|
||||
if !has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
let bytes = token.as_bytes();
|
||||
if bytes.len() >= 3
|
||||
&& bytes[0] == b'*'
|
||||
&& bytes[bytes.len() - 1] == b'*'
|
||||
&& bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
|
||||
if is_filename_constraint_token(token) {
|
||||
Some(Constraint::FilePath(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ pub enum Constraint<'a> {
|
||||
/// Path constraint: /src/ -> PathSegment("src")
|
||||
PathSegment(&'a str),
|
||||
|
||||
/// File path constraint (AI mode): "libswscale/input.c" → FilePath("libswscale/input.c")
|
||||
/// Matches files whose relative path ends with this suffix at a `/` boundary.
|
||||
FilePath(&'a str),
|
||||
|
||||
/// File type constraint: type:rust -> FileType("rust")
|
||||
FileType(&'a str),
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Glob wildcard detection — delegates to zlob when available, pure-Rust fallback otherwise.
|
||||
//!
|
||||
//! All call sites use a single function: `has_wildcards(text) -> bool`.
|
||||
//! When the `zlob` feature is enabled this calls `zlob::has_wildcards` with
|
||||
//! `ZlobFlags::RECOMMENDED`; without it we check for the same set of wildcard
|
||||
//! characters (`*`, `?`, `[`, `{`) in pure Rust.
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
zlob::has_wildcards(s, zlob::ZlobFlags::RECOMMENDED)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
|
||||
}
|
||||
@@ -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,22 +44,29 @@ 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")
|
||||
let (query_without_loc, location) = parse_location(query);
|
||||
if location.is_some() {
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Text(query_without_loc),
|
||||
location,
|
||||
});
|
||||
if config.enable_location() {
|
||||
let (query_without_loc, location) = parse_location(query);
|
||||
if location.is_some() {
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Text(query_without_loc),
|
||||
location,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Plain text single token - return None (caller handles as simple fuzzy match)
|
||||
@@ -69,8 +76,20 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
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);
|
||||
}
|
||||
@@ -82,7 +101,7 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
|
||||
// Try to extract location from the last fuzzy token
|
||||
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
|
||||
let location = if !text_parts.is_empty() {
|
||||
let location = if config.enable_location() && !text_parts.is_empty() {
|
||||
let last_idx = text_parts.len() - 1;
|
||||
let (without_loc, loc) = parse_location(text_parts[last_idx]);
|
||||
if loc.is_some() {
|
||||
@@ -145,15 +164,22 @@ impl<'a> FFFQuery<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the leading `\` from a backslash-escaped token, returning the rest.
|
||||
/// For all other tokens returns the input unchanged.
|
||||
/// 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.starts_with('\\') && token.len() > 1 {
|
||||
&token[1..]
|
||||
} else {
|
||||
token
|
||||
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> {
|
||||
@@ -184,7 +210,7 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
|
||||
// Only return Extension if the rest doesn't have wildcards
|
||||
// e.g., *.rs is Extension, but *.test.* should be Glob
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
@@ -283,7 +309,7 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
// Try extension first (*.rs) - simple patterns without additional wildcards
|
||||
if let Some(constraint) = parse_extension(token) {
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
@@ -341,11 +367,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
|
||||
@@ -428,8 +455,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);
|
||||
}
|
||||
@@ -716,6 +750,57 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_preserves_backslash_escapes() {
|
||||
// Regex patterns like \w+ and \bfoo\b must survive grep_text()
|
||||
// The parser sees \w+ as a text token (not a constraint escape),
|
||||
// but strip_leading_backslash was stripping the \ anyway.
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("pub struct \\w+")
|
||||
.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);
|
||||
@@ -785,4 +870,327 @@ mod tests {
|
||||
other => panic!("Expected Not constraint, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI grep config tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_detects_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser
|
||||
.parse("libswscale/input.c rgba32ToY")
|
||||
.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_bare_filename_is_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("main.rs pattern").expect("Should parse");
|
||||
// Bare filename with valid extension → FilePath constraint
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
|
||||
"Expected FilePath, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.grep_text(), "pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_filename_schema_rs() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser
|
||||
.parse("schema.rs part_revisions")
|
||||
.expect("Should parse");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
|
||||
"Expected FilePath(schema.rs), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.grep_text(), "part_revisions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_word_no_extension_not_constraint() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("schema pattern").expect("Should parse");
|
||||
// No extension → not a file path, just text
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
assert_eq!(result.grep_text(), "schema pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_no_false_positive_no_extension() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/utils pattern").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_no_location_parsing_single_token() {
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// localhost:8080 should NOT be parsed as location — it's a search pattern
|
||||
let result = parser.parse("localhost:8080");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Single-token grep query with colon-number should return None (plain text), got {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_no_location_parsing_multi_token() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("*.rs localhost:8080")
|
||||
.expect("should parse");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"localhost:8080",
|
||||
"Colon-number suffix should be preserved in grep text"
|
||||
);
|
||||
assert!(
|
||||
q.location.is_none(),
|
||||
"Grep should not parse location from colon-number"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+84
-76
@@ -1,52 +1,68 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 26
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 17
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
- Features |fff.nvim-features|
|
||||
- Installation |fff.nvim-installation|
|
||||
FFF.nvimFinally a smart fuzzy file picker for neovim.
|
||||
- MCP |fff.nvim-mcp|
|
||||
- Neovim guide |fff.nvim-neovim-guide|
|
||||
1. Links |fff.nvim-links|
|
||||
FFFAI agents (MCP) | Neovim usersA fast file search for your AI and neovim, with memory built-in
|
||||
|
||||
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
|
||||
opinionated fuzzy file picker for neovim. Just for files, but 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-mcp.sh <./install-mcp.sh> if you want
|
||||
to review it before running.
|
||||
It will print out the instructions on how to connect it to your `Claude Code`,
|
||||
`Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to
|
||||
"use fff". Here is an example addition to `CLAUDE.md` that works perfectly:
|
||||
|
||||
>sh
|
||||
# CLAUDE.md
|
||||
For any file search or grep in the current git indexed directory use fff tools
|
||||
<
|
||||
|
||||
|
||||
[!NOTE] Although 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
|
||||
|
||||
@@ -149,7 +165,7 @@ all available options:
|
||||
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
|
||||
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
|
||||
},
|
||||
@@ -168,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 },
|
||||
@@ -260,9 +277,8 @@ all available options:
|
||||
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,
|
||||
@@ -291,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
|
||||
@@ -313,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:
|
||||
@@ -387,6 +397,34 @@ 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
|
||||
@@ -496,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>
|
||||
|
||||
|
||||
Executable
+272
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
# FFF MCP Server installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
|
||||
|
||||
REPO="dmtrKovalenko/fff.nvim"
|
||||
BINARY_NAME="fff-mcp"
|
||||
INSTALL_DIR="${FFF_MCP_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
info() { printf '\033[1;34m%s\033[0m\n' "$*"; }
|
||||
success() { printf '\033[1;38;5;208m%s\033[0m\n' "$*"; }
|
||||
warn() { printf '\033[1;33m%s\033[0m\n' "$*"; }
|
||||
error() { printf '\033[1;31mError: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Print JSON with syntax highlighting via jq if available, plain otherwise
|
||||
print_json() {
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "$1" | jq .
|
||||
else
|
||||
echo "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
local os arch target
|
||||
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
case "$os" in
|
||||
Linux)
|
||||
# Prefer musl (static) for maximum compatibility
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-unknown-linux-musl" ;;
|
||||
aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
Darwin)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-apple-darwin" ;;
|
||||
aarch64|arm64) target="aarch64-apple-darwin" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-pc-windows-msvc" ;;
|
||||
aarch64|arm64) target="aarch64-pc-windows-msvc" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
*) error "Unsupported OS: $os" ;;
|
||||
esac
|
||||
|
||||
echo "$target"
|
||||
}
|
||||
|
||||
get_latest_release_tag() {
|
||||
local target="$1"
|
||||
local releases_json
|
||||
releases_json=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases") \
|
||||
|| error "Failed to fetch releases from https://github.com/${REPO}/releases"
|
||||
|
||||
# Find the first release that contains an fff-mcp binary for our platform
|
||||
local tag
|
||||
tag=$(echo "$releases_json" \
|
||||
| grep -oE '"(tag_name|name)": *"[^"]*"' \
|
||||
| awk -v target="fff-mcp-${target}" '
|
||||
/"tag_name":/ { gsub(/.*": *"|"/, ""); current_tag = $0; next }
|
||||
/"name":/ && index($0, target) { print current_tag; exit }
|
||||
')
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
error "No release found containing fff-mcp binaries for ${target}. The MCP build may not have been released yet."
|
||||
fi
|
||||
echo "$tag"
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local target="$1"
|
||||
local tag="$2"
|
||||
local ext=""
|
||||
|
||||
case "$target" in
|
||||
*windows*) ext=".exe" ;;
|
||||
esac
|
||||
|
||||
local filename="${BINARY_NAME}-${target}${ext}"
|
||||
local url="https://github.com/${REPO}/releases/download/${tag}/${filename}"
|
||||
local checksum_url="${url}.sha256"
|
||||
|
||||
info "Downloading ${filename} from release ${tag}..."
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url" 2>/dev/null; then
|
||||
echo "" >&2
|
||||
printf '\033[1;31mError: Failed to download binary for your platform.\033[0m\n' >&2
|
||||
echo "" >&2
|
||||
echo " URL: ${url}" >&2
|
||||
echo " Release: ${tag}" >&2
|
||||
echo " Platform: ${target}" >&2
|
||||
echo "" >&2
|
||||
echo "This likely means the MCP binary hasn't been built for this release yet." >&2
|
||||
echo "Check available releases at: https://github.com/${REPO}/releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify checksum if sha256sum is available
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
if curl -fsSL -o "${tmp_dir}/${filename}.sha256" "$checksum_url" 2>/dev/null; then
|
||||
info "Verifying checksum..."
|
||||
(cd "$tmp_dir" && sha256sum -c "${filename}.sha256") \
|
||||
|| error "Checksum verification failed!"
|
||||
else
|
||||
warn "Checksum file not available, skipping verification."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
|
||||
if [ "$IS_UPDATE" != true ]; then
|
||||
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_path() {
|
||||
case ":$PATH:" in
|
||||
*":${INSTALL_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
|
||||
warn "${INSTALL_DIR} is not in your PATH."
|
||||
echo ""
|
||||
echo "Add it to your shell profile:"
|
||||
echo ""
|
||||
|
||||
local shell_name
|
||||
shell_name="$(basename "${SHELL:-bash}")"
|
||||
case "$shell_name" in
|
||||
zsh)
|
||||
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc"
|
||||
echo " source ~/.zshrc"
|
||||
;;
|
||||
fish)
|
||||
echo " fish_add_path ${INSTALL_DIR}"
|
||||
;;
|
||||
*)
|
||||
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc"
|
||||
echo " source ~/.bashrc"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_setup_instructions() {
|
||||
local binary_path="${INSTALL_DIR}/${BINARY_NAME}"
|
||||
local found_any=false
|
||||
|
||||
echo ""
|
||||
success "FFF MCP Server installed successfully!"
|
||||
echo ""
|
||||
info "Setup with your AI coding assistant:"
|
||||
echo ""
|
||||
|
||||
# Claude Code
|
||||
if command -v claude &>/dev/null; then
|
||||
found_any=true
|
||||
success "[Claude Code] detected"
|
||||
echo ""
|
||||
echo "Global (recommended):"
|
||||
echo "claude mcp add -s user fff -- ${binary_path}"
|
||||
echo ""
|
||||
echo "Or project-level .mcp.json (uses PATH):"
|
||||
echo ""
|
||||
print_json '{
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# OpenCode
|
||||
if command -v opencode &>/dev/null; then
|
||||
found_any=true
|
||||
success "[OpenCode] detected"
|
||||
echo ""
|
||||
echo "Add to ~/.config/opencode/opencode.json:"
|
||||
echo ""
|
||||
print_json '{
|
||||
"mcp": {
|
||||
"fff": {
|
||||
"type": "local",
|
||||
"command": ["fff-mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Codex
|
||||
if command -v codex &>/dev/null; then
|
||||
found_any=true
|
||||
success "[Codex] detected"
|
||||
echo ""
|
||||
echo "codex mcp add fff -- fff-mcp"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$found_any" = false ]; then
|
||||
echo "No AI coding assistants detected."
|
||||
echo ""
|
||||
echo "Binary path: ${binary_path}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Binary: ${binary_path}"
|
||||
echo "Docs: https://github.com/${REPO}"
|
||||
echo ""
|
||||
info "Tip: Add this to your CLAUDE.md or AGENTS.md to make AI use fff for all searches:"
|
||||
echo "\""
|
||||
echo "Use the fff MCP tools for all file search operations instead of default tools."
|
||||
echo "\""
|
||||
|
||||
|
||||
}
|
||||
|
||||
main() {
|
||||
local target
|
||||
target="$(detect_platform)"
|
||||
|
||||
local existing_binary="${INSTALL_DIR}/${BINARY_NAME}"
|
||||
IS_UPDATE=false
|
||||
|
||||
if [ -x "$existing_binary" ]; then
|
||||
IS_UPDATE=true
|
||||
info "Updating FFF MCP Server..."
|
||||
else
|
||||
info "Installing FFF MCP Server..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
info "Detected platform: ${target}"
|
||||
|
||||
local tag
|
||||
tag="$(get_latest_release_tag "$target")"
|
||||
|
||||
download_binary "$target" "$tag"
|
||||
|
||||
if [ "$IS_UPDATE" = true ]; then
|
||||
echo ""
|
||||
success "FFF MCP Server updated to ${tag}!"
|
||||
echo ""
|
||||
else
|
||||
check_path
|
||||
print_setup_instructions
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
+5
-4
@@ -16,6 +16,7 @@ local M = {}
|
||||
--- @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>
|
||||
|
||||
@@ -219,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 },
|
||||
@@ -311,9 +313,8 @@ local function init()
|
||||
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,
|
||||
@@ -354,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
|
||||
|
||||
@@ -65,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
|
||||
|
||||
@@ -114,7 +114,7 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
vim.uv.fanoushkas_unlink(tmp_path)
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -732,7 +732,12 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
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 })
|
||||
vim.api.nvim_set_option_value('wrap', false, { 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
|
||||
|
||||
@@ -180,7 +180,7 @@ 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.cursor)), 'bg')
|
||||
|
||||
@@ -47,7 +47,9 @@ end
|
||||
local function render_match_line(item, ctx)
|
||||
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
|
||||
local separator = ' '
|
||||
local raw_content = item.line_content or ''
|
||||
-- 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
|
||||
|
||||
+20
-12
@@ -69,8 +69,16 @@ local function generate_item_lines(ctx)
|
||||
local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0
|
||||
if has_suggestion_header then
|
||||
table.insert(suggestion_header_lines, '')
|
||||
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
|
||||
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
|
||||
if ctx.mode == 'grep' and ctx.suggestion_source == 'files' then
|
||||
-- Grep mode with no results — hint about mode cycling to fuzzy search
|
||||
local config = require('fff.conf').get()
|
||||
local keybind = config.keymaps.cycle_grep_modes
|
||||
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
|
||||
table.insert(suggestion_header_lines, ' No results, try ' .. keybind .. ' to fuzzy search')
|
||||
else
|
||||
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
|
||||
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
|
||||
end
|
||||
table.insert(suggestion_header_lines, '')
|
||||
end
|
||||
|
||||
@@ -189,17 +197,17 @@ local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
local item = ctx.items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
if item_lines then
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
if not line_content then goto continue end
|
||||
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
if line_content then
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -224,7 +232,7 @@ function M.render(ctx, list_buf, list_win, ns_id)
|
||||
local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg'
|
||||
for i = 0, #lines - 1 do
|
||||
local line = lines[i + 1]
|
||||
if line and line:match('^%s+No results found') then
|
||||
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,
|
||||
|
||||
+9
-25
@@ -42,6 +42,8 @@ function M.live_grep(opts)
|
||||
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)
|
||||
@@ -163,11 +165,12 @@ function M.find_files_in_dir(directory)
|
||||
return
|
||||
end
|
||||
|
||||
M.change_indexing_directory(directory)
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
|
||||
picker_ui.open({
|
||||
title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t'),
|
||||
cwd = directory,
|
||||
})
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
@@ -177,28 +180,9 @@ end
|
||||
--- @param new_path string New directory path to use as base
|
||||
--- @return boolean `true` if successful, `false` otherwise
|
||||
function M.change_indexing_directory(new_path)
|
||||
if not new_path or new_path == '' then
|
||||
vim.notify('Directory path is required', vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local expanded_path = vim.fn.expand(new_path)
|
||||
|
||||
if vim.fn.isdirectory(expanded_path) ~= 1 then
|
||||
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local config = require('fff.conf').get()
|
||||
config.base_path = expanded_path
|
||||
return true
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then return picker_ui.change_indexing_directory(new_path) end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Opens the file under the cursor with an optional callback if the only file
|
||||
|
||||
+90
-4
@@ -90,7 +90,7 @@ local function get_preview_position()
|
||||
end
|
||||
|
||||
local function compute_layout(config)
|
||||
local debug_enabled_in_preview = M.enabled_preview() and config.debug and config.debug.show_file_info or false
|
||||
local debug_enabled_in_preview = M.enabled_preview() and config.debug and config.debug.enabled or false
|
||||
|
||||
local terminal_width = vim.o.columns
|
||||
local terminal_height = vim.o.lines
|
||||
@@ -638,9 +638,49 @@ function M.setup_windows()
|
||||
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.list_win })
|
||||
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.list_win })
|
||||
|
||||
if M.state.file_info_win and vim.api.nvim_win_is_valid(M.state.file_info_win) then
|
||||
vim.api.nvim_set_option_value('wrap', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('cursorline', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('number', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('signcolumn', 'no', { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.file_info_win })
|
||||
end
|
||||
|
||||
if M.enabled_preview() then
|
||||
vim.api.nvim_set_option_value('wrap', false, { win = M.state.preview_win })
|
||||
vim.api.nvim_set_option_value('cursorline', M.state.mode == 'grep', { win = M.state.preview_win })
|
||||
|
||||
local cursorlineopt = utils.resolve_config_value(
|
||||
preview_config.cursorlineopt,
|
||||
vim.o.columns,
|
||||
vim.o.lines,
|
||||
function(value)
|
||||
if type(value) ~= 'string' or #value == 0 then return false end
|
||||
|
||||
local has_line = false
|
||||
local has_screenline = false
|
||||
for opt in value:gmatch('[^,]+') do
|
||||
if not utils.is_one_of(opt:gsub('%s+', ''), { 'line', 'screenline', 'number', 'both' }) then return false end
|
||||
if opt == 'line' or opt == 'both' then has_line = true end
|
||||
if opt == 'screenline' then has_screenline = true end
|
||||
end
|
||||
|
||||
if has_line and has_screenline then return false end
|
||||
|
||||
return true
|
||||
end,
|
||||
'both',
|
||||
'preview.cursorlineopt'
|
||||
)
|
||||
|
||||
vim.api.nvim_set_option_value(
|
||||
'cursorlineopt',
|
||||
M.state.mode == 'grep' and cursorlineopt or vim.o.cursorlineopt,
|
||||
{ win = M.state.preview_win }
|
||||
)
|
||||
|
||||
vim.api.nvim_set_option_value(
|
||||
'number',
|
||||
M.state.mode == 'grep' or (preview_config and preview_config.line_numbers or false),
|
||||
@@ -1316,7 +1356,10 @@ local function format_file_display(item, max_width)
|
||||
return filename, display_path
|
||||
end
|
||||
|
||||
--- Adjust scroll for bottom prompt to eliminate gaps
|
||||
--- Adjust scroll for bottom prompt to eliminate gaps.
|
||||
--- When the cursor has moved above the bottom viewport (e.g. user scrolled up
|
||||
--- through many results), follow the cursor instead of forcing the view to the
|
||||
--- bottom — otherwise the selected item disappears off the top of the window.
|
||||
local function scroll_to_bottom()
|
||||
if not M.state.list_win or not vim.api.nvim_win_is_valid(M.state.list_win) then return end
|
||||
|
||||
@@ -1325,8 +1368,21 @@ local function scroll_to_bottom()
|
||||
|
||||
vim.api.nvim_win_call(M.state.list_win, function()
|
||||
local view = vim.fn.winsaveview()
|
||||
-- Force topline to show content at bottom
|
||||
view.topline = math.max(1, buf_lines - win_height + 1)
|
||||
local bottom_topline = math.max(1, buf_lines - win_height + 1)
|
||||
local cursor_line = vim.api.nvim_win_get_cursor(M.state.list_win)[1]
|
||||
|
||||
if cursor_line >= bottom_topline then
|
||||
-- Cursor is visible when anchored to bottom — keep content near prompt
|
||||
view.topline = bottom_topline
|
||||
elseif cursor_line < view.topline then
|
||||
-- Cursor scrolled above the current viewport — shift topline up just
|
||||
-- enough to keep the cursor visible (1 line margin above)
|
||||
view.topline = math.max(1, cursor_line - 1)
|
||||
elseif cursor_line >= view.topline + win_height then
|
||||
-- Cursor below viewport (shouldn't happen often) — snap to bottom
|
||||
view.topline = bottom_topline
|
||||
end
|
||||
-- Otherwise cursor is already within the current viewport — don't move it
|
||||
vim.fn.winrestview(view)
|
||||
end)
|
||||
end
|
||||
@@ -2545,6 +2601,8 @@ function M.open(opts)
|
||||
local merged_config, base_path = initialize_picker(opts)
|
||||
if not merged_config then return end
|
||||
|
||||
if base_path then M.change_indexing_directory(base_path) end
|
||||
|
||||
-- Initialize grep_mode to first configured mode when opening in grep mode
|
||||
if M.state.mode == 'grep' then
|
||||
-- Use grep_config.modes if provided, otherwise fall back to global config
|
||||
@@ -2560,6 +2618,34 @@ function M.open(opts)
|
||||
return open_ui_with_state(query, nil, nil, merged_config, current_file_cache)
|
||||
end
|
||||
|
||||
--- Change the base directory for the file picker
|
||||
--- @param new_path string New directory path to use as base
|
||||
--- @return boolean `true` if successful, `false` otherwise
|
||||
function M.change_indexing_directory(new_path)
|
||||
if not new_path or new_path == '' then
|
||||
vim.notify('Directory path is required', vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local expanded_path = vim.fn.expand(new_path)
|
||||
|
||||
if vim.fn.isdirectory(expanded_path) ~= 1 then
|
||||
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local config = require('fff.conf').get()
|
||||
config.base_path = expanded_path
|
||||
return true
|
||||
end
|
||||
|
||||
function M.monitor_scan_progress(iteration)
|
||||
if not M.state.active then return 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"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Native binaries (downloaded at install)
|
||||
bin/*.dylib
|
||||
bin/*.so
|
||||
bin/*.dll
|
||||
Executable
BIN
Binary file not shown.
@@ -8,6 +8,16 @@
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-x64": "0.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0",
|
||||
},
|
||||
|
||||
Regular → Executable
+15
-30
@@ -11,7 +11,7 @@
|
||||
|
||||
import { FileFinder } from "../src/index";
|
||||
import type { GrepMode } from "../src/types";
|
||||
import * as readline from "readline";
|
||||
import * as readline from "node:readline";
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const BOLD = "\x1b[1m";
|
||||
@@ -64,9 +64,7 @@ function highlightLine(content: string, ranges: [number, number][]): string {
|
||||
if (s > lastEnd) {
|
||||
parts.push(buf.subarray(lastEnd, s).toString("utf-8"));
|
||||
}
|
||||
parts.push(
|
||||
`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`
|
||||
);
|
||||
parts.push(`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`);
|
||||
lastEnd = e;
|
||||
}
|
||||
|
||||
@@ -131,7 +129,7 @@ async function main() {
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
process.stdout.write(
|
||||
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `
|
||||
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `,
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
@@ -139,19 +137,17 @@ async function main() {
|
||||
|
||||
const scanTime = Date.now() - startTime;
|
||||
const finalProgress = finder.getScanProgress();
|
||||
const totalFiles = finalProgress.ok
|
||||
? finalProgress.value.scannedFilesCount
|
||||
: 0;
|
||||
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
|
||||
|
||||
console.log(
|
||||
`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`
|
||||
`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`
|
||||
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`,
|
||||
);
|
||||
console.log(
|
||||
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`
|
||||
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`,
|
||||
);
|
||||
|
||||
let currentMode = mode;
|
||||
@@ -176,16 +172,12 @@ async function main() {
|
||||
// Handle mode switching
|
||||
if (query.startsWith(":mode ")) {
|
||||
const newMode = query.slice(6).trim();
|
||||
if (
|
||||
newMode === "plain" ||
|
||||
newMode === "regex" ||
|
||||
newMode === "fuzzy"
|
||||
) {
|
||||
if (newMode === "plain" || newMode === "regex" || newMode === "fuzzy") {
|
||||
currentMode = newMode;
|
||||
console.log(`${DIM}Switched to ${currentMode} mode${RESET}\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`
|
||||
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`,
|
||||
);
|
||||
}
|
||||
prompt();
|
||||
@@ -215,7 +207,7 @@ async function main() {
|
||||
items,
|
||||
totalMatched,
|
||||
totalFilesSearched,
|
||||
totalFiles: indexedFiles,
|
||||
totalFiles: _,
|
||||
filteredFileCount,
|
||||
nextCursor,
|
||||
regexFallbackError,
|
||||
@@ -225,12 +217,12 @@ async function main() {
|
||||
|
||||
if (regexFallbackError) {
|
||||
console.log(
|
||||
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`
|
||||
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`
|
||||
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
@@ -246,16 +238,11 @@ async function main() {
|
||||
if (match.relativePath !== lastFile) {
|
||||
lastFile = match.relativePath;
|
||||
const git = formatGitStatus(match.gitStatus);
|
||||
console.log(
|
||||
`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`
|
||||
);
|
||||
console.log(`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`);
|
||||
}
|
||||
|
||||
const lineNum = String(match.lineNumber).padStart(4);
|
||||
const highlighted = highlightLine(
|
||||
match.lineContent,
|
||||
match.matchRanges
|
||||
);
|
||||
const highlighted = highlightLine(match.lineContent, match.matchRanges);
|
||||
|
||||
let suffix = "";
|
||||
if (match.fuzzyScore !== undefined) {
|
||||
@@ -266,9 +253,7 @@ async function main() {
|
||||
}
|
||||
|
||||
if (nextCursor) {
|
||||
console.log(
|
||||
`\n${DIM}... more results available${RESET}`
|
||||
);
|
||||
console.log(`\n${DIM}... more results available${RESET}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Interactive file finder demo
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* Usage:
|
||||
* bunx fff-demo [directory]
|
||||
* bun examples/search.ts [directory]
|
||||
*
|
||||
*
|
||||
* Indexes the specified directory (or cwd) and provides an interactive
|
||||
* search prompt with detailed metadata about results.
|
||||
*/
|
||||
|
||||
import { FileFinder } from "../src/index";
|
||||
import * as readline from "readline";
|
||||
import * as readline from "node:readline";
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const BOLD = "\x1b[1m";
|
||||
@@ -19,7 +19,6 @@ const DIM = "\x1b[2m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const BLUE = "\x1b[34m";
|
||||
const MAGENTA = "\x1b[35m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const RED = "\x1b[31m";
|
||||
|
||||
@@ -74,7 +73,7 @@ function formatTime(unixSeconds: number): string {
|
||||
|
||||
async function main() {
|
||||
const targetDir = process.argv[2] || process.cwd();
|
||||
|
||||
|
||||
console.log(`${BOLD}${CYAN}fff - Fast File Finder Demo${RESET}\n`);
|
||||
|
||||
// Check library availability
|
||||
@@ -115,7 +114,9 @@ async function main() {
|
||||
const finalProgress = finder.getScanProgress();
|
||||
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
|
||||
|
||||
console.log(`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`);
|
||||
console.log(
|
||||
`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`,
|
||||
);
|
||||
|
||||
// Show index info
|
||||
const health = finder.healthCheck();
|
||||
@@ -133,7 +134,9 @@ async function main() {
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
console.log(`${BOLD}Enter a search query${RESET} (or 'q' to quit, empty for all files):\n`);
|
||||
console.log(
|
||||
`${BOLD}Enter a search query${RESET} (or 'q' to quit, empty for all files):\n`,
|
||||
);
|
||||
|
||||
const prompt = () => {
|
||||
rl.question(`${CYAN}search>${RESET} `, (query) => {
|
||||
@@ -158,7 +161,7 @@ async function main() {
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`
|
||||
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
@@ -169,9 +172,7 @@ async function main() {
|
||||
}
|
||||
|
||||
// Header
|
||||
console.log(
|
||||
`${DIM} Git │ Score │ Size │ Modified │ Path${RESET}`
|
||||
);
|
||||
console.log(`${DIM} Git │ Score │ Size │ Modified │ Path${RESET}`);
|
||||
|
||||
// Results
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
@@ -185,7 +186,7 @@ async function main() {
|
||||
const path = item.relativePath;
|
||||
|
||||
console.log(
|
||||
` ${gitStatus} │ ${totalScore.padStart(5)} │ ${size} │ ${modified} │ ${path}`
|
||||
` ${gitStatus} │ ${totalScore.padStart(5)} │ ${size} │ ${modified} │ ${path}`,
|
||||
);
|
||||
|
||||
// Show score breakdown for top results
|
||||
@@ -194,19 +195,23 @@ async function main() {
|
||||
if (score.baseScore > 0) breakdown.push(`base:${score.baseScore}`);
|
||||
if (score.filenameBonus > 0) breakdown.push(`filename:+${score.filenameBonus}`);
|
||||
if (score.frecencyBoost > 0) breakdown.push(`frecency:+${score.frecencyBoost}`);
|
||||
if (score.comboMatchBoost > 0) breakdown.push(`combo:+${score.comboMatchBoost}`);
|
||||
if (score.distancePenalty < 0) breakdown.push(`distance:${score.distancePenalty}`);
|
||||
if (score.comboMatchBoost > 0)
|
||||
breakdown.push(`combo:+${score.comboMatchBoost}`);
|
||||
if (score.distancePenalty < 0)
|
||||
breakdown.push(`distance:${score.distancePenalty}`);
|
||||
if (score.exactMatch) breakdown.push(`${GREEN}exact${RESET}`);
|
||||
|
||||
|
||||
if (breakdown.length > 0) {
|
||||
console.log(`${DIM} │ │ │ │ └─ ${breakdown.join(", ")}${RESET}`);
|
||||
console.log(
|
||||
`${DIM} │ │ │ │ └─ ${breakdown.join(", ")}${RESET}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalMatched > items.length) {
|
||||
console.log(
|
||||
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`
|
||||
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
* bunx fff info - Show platform and binary info
|
||||
*/
|
||||
|
||||
import {
|
||||
downloadBinary,
|
||||
getBinaryPath,
|
||||
findBinary,
|
||||
} from "../src/download";
|
||||
import { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "../src/platform";
|
||||
import { downloadBinary, getBinaryPath, findBinary } from "../src/download";
|
||||
import {
|
||||
getTriple,
|
||||
getLibExtension,
|
||||
getLibFilename,
|
||||
getNpmPackageName,
|
||||
} from "../src/platform";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -26,7 +27,7 @@ interface PackageJson {
|
||||
async function getPackageInfo(): Promise<PackageJson> {
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = join(currentDir, "..", "package.json");
|
||||
|
||||
|
||||
try {
|
||||
return await Bun.file(packageJsonPath).json();
|
||||
} catch {
|
||||
@@ -57,7 +58,7 @@ async function main() {
|
||||
} catch {
|
||||
npmPackage = "unsupported";
|
||||
}
|
||||
|
||||
|
||||
console.log("fff - Fast File Finder");
|
||||
console.log(`Package version: ${pkg.version}`);
|
||||
console.log("");
|
||||
@@ -87,15 +88,14 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
default: {
|
||||
const pkg = await getPackageInfo();
|
||||
console.log(`fff - Fast File Finder CLI v${pkg.version}`);
|
||||
console.log("");
|
||||
console.log("Usage:");
|
||||
console.log(" bunx fff download [tag] Download native binary from GitHub (fallback)");
|
||||
console.log(
|
||||
" bunx fff download [tag] Download native binary from GitHub (fallback)",
|
||||
);
|
||||
console.log(" bunx fff info Show platform and binary info");
|
||||
console.log(" bunx fff version Show version");
|
||||
console.log(" bunx fff help Show this help message");
|
||||
@@ -104,7 +104,9 @@ async function main() {
|
||||
console.log(" bunx fff download Download latest binary from GitHub");
|
||||
console.log(" bunx fff download abc1234 Download specific release tag");
|
||||
console.log("");
|
||||
console.log("Note: Binaries are normally provided via platform-specific npm packages.");
|
||||
console.log(
|
||||
"Note: Binaries are normally provided via platform-specific npm packages.",
|
||||
);
|
||||
console.log("The download command is a fallback for when those aren't available.");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ async function main() {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`fff: Platform package ${packageName} not found, falling back to GitHub download...`
|
||||
`fff: Platform package ${packageName} not found, falling back to GitHub download...`,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -40,9 +40,7 @@ async function main() {
|
||||
console.error("fff: You can build from source instead:");
|
||||
console.error(" cargo build --release -p fff-c");
|
||||
console.error("");
|
||||
console.error(
|
||||
"fff: Or run `bunx fff download` after fixing network issues."
|
||||
);
|
||||
console.error("fff: Or run `bunx fff download` after fixing network issues.");
|
||||
// Don't exit with error - allow install to complete
|
||||
// The error will surface when the user tries to use the library
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
* 4. GitHub releases (fallback, requires network)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
getTriple,
|
||||
getLibExtension,
|
||||
getLibFilename,
|
||||
getNpmPackageName,
|
||||
getTriple,
|
||||
} from "./platform";
|
||||
|
||||
const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
|
||||
@@ -26,6 +26,13 @@ const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
|
||||
*/
|
||||
function getCurrentDir(): string {
|
||||
const url = import.meta.url;
|
||||
|
||||
// When running in a compiled Bun binary, import.meta.url points to the virtual
|
||||
// $bunfs filesystem. Use process.execPath to get the real filesystem location.
|
||||
if (url.includes("$bunfs")) {
|
||||
return dirname(process.execPath);
|
||||
}
|
||||
|
||||
if (url.startsWith("file://")) {
|
||||
return dirname(fileURLToPath(url));
|
||||
}
|
||||
|
||||
+47
-60
@@ -8,8 +8,8 @@
|
||||
* be passed to all subsequent calls and freed with `ffiDestroy`.
|
||||
*/
|
||||
|
||||
import { dlopen, FFIType, ptr, CString, read, type Pointer } from "bun:ffi";
|
||||
import { findBinary, ensureBinary } from "./download";
|
||||
import { CString, dlopen, FFIType, type Pointer, ptr, read } from "bun:ffi";
|
||||
import { ensureBinary, findBinary } from "./download";
|
||||
import type { Result } from "./types";
|
||||
import { err } from "./types";
|
||||
|
||||
@@ -37,6 +37,12 @@ const ffiDefinition = {
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Multi-pattern grep (Aho-Corasick)
|
||||
fff_multi_grep: {
|
||||
args: [FFIType.ptr, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// File index
|
||||
fff_scan_files: {
|
||||
args: [FFIType.ptr],
|
||||
@@ -59,12 +65,6 @@ const ffiDefinition = {
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Frecency
|
||||
fff_track_access: {
|
||||
args: [FFIType.ptr, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Git
|
||||
fff_refresh_git_status: {
|
||||
args: [FFIType.ptr],
|
||||
@@ -112,7 +112,7 @@ function loadLibrary(): FFFLibrary {
|
||||
const binaryPath = findBinary();
|
||||
if (!binaryPath) {
|
||||
throw new Error(
|
||||
"fff native library not found. Run `bunx fff download` or build from source with `cargo build --release -p fff-c`"
|
||||
"fff native library not found. Run `bunx fff download` or build from source with `cargo build --release -p fff-c`",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ function loadLibrary(): FFFLibrary {
|
||||
* Encode a string for FFI (null-terminated)
|
||||
*/
|
||||
function encodeString(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s + "\0");
|
||||
return new TextEncoder().encode(`${s}\0`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,9 +148,7 @@ function snakeToCamel(obj: unknown): unknown {
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
|
||||
letter.toUpperCase()
|
||||
);
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
result[camelKey] = snakeToCamel(value);
|
||||
}
|
||||
return result;
|
||||
@@ -258,13 +256,13 @@ export function ffiDestroy(handle: NativeHandle): void {
|
||||
export function ffiSearch(
|
||||
handle: NativeHandle,
|
||||
query: string,
|
||||
optsJson: string
|
||||
optsJson: string,
|
||||
): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_search(
|
||||
handle,
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(optsJson))
|
||||
ptr(encodeString(optsJson)),
|
||||
);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
@@ -298,15 +296,9 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
|
||||
/**
|
||||
* Wait for scan to complete.
|
||||
*/
|
||||
export function ffiWaitForScan(
|
||||
handle: NativeHandle,
|
||||
timeoutMs: number
|
||||
): Result<boolean> {
|
||||
export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(
|
||||
handle,
|
||||
BigInt(timeoutMs)
|
||||
);
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs));
|
||||
const result = parseResult<boolean | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
// JSON.parse("true") returns boolean true, but we also handle
|
||||
@@ -317,35 +309,12 @@ export function ffiWaitForScan(
|
||||
/**
|
||||
* Restart index in new path.
|
||||
*/
|
||||
export function ffiRestartIndex(
|
||||
handle: NativeHandle,
|
||||
newPath: string
|
||||
): Result<void> {
|
||||
export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_restart_index(
|
||||
handle,
|
||||
ptr(encodeString(newPath))
|
||||
);
|
||||
const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath)));
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track file access.
|
||||
*/
|
||||
export function ffiTrackAccess(
|
||||
handle: NativeHandle,
|
||||
filePath: string
|
||||
): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_track_access(
|
||||
handle,
|
||||
ptr(encodeString(filePath))
|
||||
);
|
||||
const result = parseResult<boolean | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: result.value === true || result.value === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh git status.
|
||||
*/
|
||||
@@ -355,7 +324,10 @@ export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
|
||||
const result = parseResult<number | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
// JSON.parse("3") returns 3 (number), parseInt handles both
|
||||
return { ok: true, value: typeof result.value === "number" ? result.value : parseInt(result.value, 10) };
|
||||
return {
|
||||
ok: true,
|
||||
value: typeof result.value === "number" ? result.value : parseInt(result.value, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,13 +336,13 @@ export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
|
||||
export function ffiTrackQuery(
|
||||
handle: NativeHandle,
|
||||
query: string,
|
||||
filePath: string
|
||||
filePath: string,
|
||||
): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_track_query(
|
||||
handle,
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(filePath))
|
||||
ptr(encodeString(filePath)),
|
||||
);
|
||||
const result = parseResult<boolean | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
@@ -382,13 +354,10 @@ export function ffiTrackQuery(
|
||||
*/
|
||||
export function ffiGetHistoricalQuery(
|
||||
handle: NativeHandle,
|
||||
offset: number
|
||||
offset: number,
|
||||
): Result<string | null> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_get_historical_query(
|
||||
handle,
|
||||
BigInt(offset)
|
||||
);
|
||||
const resultPtr = library.symbols.fff_get_historical_query(handle, BigInt(offset));
|
||||
const result = parseResult<string | null>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
if (result.value === null || result.value === "null") return { ok: true, value: null };
|
||||
@@ -402,33 +371,51 @@ export function ffiGetHistoricalQuery(
|
||||
*/
|
||||
export function ffiHealthCheck(
|
||||
handle: NativeHandle | null,
|
||||
testPath: string
|
||||
testPath: string,
|
||||
): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_health_check(
|
||||
handle ?? (0 as unknown as Pointer),
|
||||
ptr(encodeString(testPath))
|
||||
ptr(encodeString(testPath)),
|
||||
);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect workspace roots in the indexed directory.
|
||||
*/
|
||||
export function ffiDetectWorkspaces(handle: NativeHandle): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_detect_workspaces(handle);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live grep - search file contents.
|
||||
*/
|
||||
export function ffiLiveGrep(
|
||||
handle: NativeHandle,
|
||||
query: string,
|
||||
optsJson: string
|
||||
optsJson: string,
|
||||
): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_live_grep(
|
||||
handle,
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(optsJson))
|
||||
ptr(encodeString(optsJson)),
|
||||
);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-pattern grep - Aho-Corasick multi-needle search.
|
||||
*/
|
||||
export function ffiMultiGrep(handle: NativeHandle, optsJson: string): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_multi_grep(handle, ptr(encodeString(optsJson)));
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the library is loaded (for preloading).
|
||||
*/
|
||||
|
||||
@@ -9,44 +9,65 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ensureLoaded,
|
||||
ffiCreate,
|
||||
ffiDestroy,
|
||||
ffiSearch,
|
||||
ffiLiveGrep,
|
||||
ffiScanFiles,
|
||||
ffiIsScanning,
|
||||
ffiGetScanProgress,
|
||||
ffiWaitForScan,
|
||||
ffiRestartIndex,
|
||||
ffiTrackAccess,
|
||||
ffiRefreshGitStatus,
|
||||
ffiTrackQuery,
|
||||
ffiGetHistoricalQuery,
|
||||
ffiGetScanProgress,
|
||||
ffiHealthCheck,
|
||||
ensureLoaded,
|
||||
ffiIsScanning,
|
||||
ffiLiveGrep,
|
||||
ffiMultiGrep,
|
||||
ffiRefreshGitStatus,
|
||||
ffiRestartIndex,
|
||||
ffiScanFiles,
|
||||
ffiSearch,
|
||||
ffiTrackQuery,
|
||||
ffiWaitForScan,
|
||||
isAvailable,
|
||||
type NativeHandle,
|
||||
} from "./ffi";
|
||||
|
||||
import type {
|
||||
Result,
|
||||
InitOptions,
|
||||
SearchOptions,
|
||||
SearchResult,
|
||||
ScanProgress,
|
||||
HealthCheck,
|
||||
GrepOptions,
|
||||
GrepResult,
|
||||
HealthCheck,
|
||||
InitOptions,
|
||||
MultiGrepOptions,
|
||||
Result,
|
||||
ScanProgress,
|
||||
SearchOptions,
|
||||
SearchResult,
|
||||
} from "./types";
|
||||
|
||||
import {
|
||||
err,
|
||||
toInternalInitOptions,
|
||||
toInternalSearchOptions,
|
||||
toInternalGrepOptions,
|
||||
createGrepCursor,
|
||||
err,
|
||||
toInternalGrepOptions,
|
||||
toInternalInitOptions,
|
||||
toInternalMultiGrepOptions,
|
||||
toInternalSearchOptions,
|
||||
} from "./types";
|
||||
|
||||
/** Transform raw FFI grep result into typed GrepResult with opaque cursor. */
|
||||
function transformGrepResult(result: Result<unknown>): Result<GrepResult> {
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
const raw = result.value as Record<string, unknown>;
|
||||
const nextFileOffset = raw.nextFileOffset as number;
|
||||
const grepResult: GrepResult = {
|
||||
items: raw.items as GrepResult["items"],
|
||||
totalMatched: raw.totalMatched as number,
|
||||
totalFilesSearched: raw.totalFilesSearched as number,
|
||||
totalFiles: raw.totalFiles as number,
|
||||
filteredFileCount: raw.filteredFileCount as number,
|
||||
nextCursor: nextFileOffset > 0 ? createGrepCursor(nextFileOffset) : null,
|
||||
regexFallbackError: raw.regexFallbackError as string | undefined,
|
||||
};
|
||||
return { ok: true, value: grepResult };
|
||||
}
|
||||
|
||||
/**
|
||||
* FileFinder - Fast file finder with fuzzy search
|
||||
*
|
||||
@@ -207,7 +228,7 @@ export class FileFinder {
|
||||
* @example
|
||||
* ```typescript
|
||||
* // First page
|
||||
* const result = finder.liveGrep("TODO", { mode: "plain" });
|
||||
* const result = finder.liveGrep("TODO", { mode: "plain" });
|
||||
* if (result.ok) {
|
||||
* for (const match of result.value.items) {
|
||||
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
|
||||
@@ -226,31 +247,48 @@ export class FileFinder {
|
||||
if (!guard.ok) return guard;
|
||||
|
||||
const internalOpts = toInternalGrepOptions(options);
|
||||
const result = ffiLiveGrep(
|
||||
guard.value,
|
||||
query,
|
||||
JSON.stringify(internalOpts)
|
||||
);
|
||||
const result = ffiLiveGrep(guard.value, query, JSON.stringify(internalOpts));
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
return transformGrepResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-pattern OR search using Aho-Corasick.
|
||||
*
|
||||
* Searches for lines matching ANY of the provided patterns using
|
||||
* SIMD-accelerated multi-needle matching. Faster than regex alternation
|
||||
* for literal text searches.
|
||||
*
|
||||
* Supports pagination. The result includes a `nextCursor` that can be
|
||||
* passed back to fetch the next page.
|
||||
*
|
||||
* @param options - Multi-grep options including patterns and optional constraints
|
||||
* @returns Grep results with matched lines and file metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = finder.multiGrep({
|
||||
* patterns: ["VideoFrame", "video_frame", "PreloadedImage"],
|
||||
* });
|
||||
* if (result.ok) {
|
||||
* for (const match of result.value.items) {
|
||||
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
multiGrep(options: MultiGrepOptions): Result<GrepResult> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
|
||||
if (!options.patterns || options.patterns.length === 0) {
|
||||
return err("patterns array must have at least 1 element");
|
||||
}
|
||||
|
||||
// Transform the raw FFI result: replace nextFileOffset with an opaque cursor
|
||||
const raw = result.value as Record<string, unknown>;
|
||||
const nextFileOffset = raw.nextFileOffset as number;
|
||||
const internalOpts = toInternalMultiGrepOptions(options);
|
||||
const result = ffiMultiGrep(guard.value, JSON.stringify(internalOpts));
|
||||
|
||||
const grepResult: GrepResult = {
|
||||
items: raw.items as GrepResult["items"],
|
||||
totalMatched: raw.totalMatched as number,
|
||||
totalFilesSearched: raw.totalFilesSearched as number,
|
||||
totalFiles: raw.totalFiles as number,
|
||||
filteredFileCount: raw.filteredFileCount as number,
|
||||
nextCursor: nextFileOffset > 0 ? createGrepCursor(nextFileOffset) : null,
|
||||
regexFallbackError: raw.regexFallbackError as string | undefined,
|
||||
};
|
||||
|
||||
return { ok: true, value: grepResult };
|
||||
return transformGrepResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,19 +356,6 @@ export class FileFinder {
|
||||
return ffiRestartIndex(guard.value, newPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track file access for frecency scoring.
|
||||
*
|
||||
* Call this when a user opens a file to improve future search rankings.
|
||||
*
|
||||
* @param filePath - Absolute path to the accessed file
|
||||
*/
|
||||
trackAccess(filePath: string): Result<boolean> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiTrackAccess(guard.value, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the git status cache.
|
||||
*
|
||||
@@ -377,10 +402,7 @@ export class FileFinder {
|
||||
* @param testPath - Optional path to test git repository detection
|
||||
*/
|
||||
healthCheck(testPath?: string): Result<HealthCheck> {
|
||||
return ffiHealthCheck(
|
||||
this.handle,
|
||||
testPath || ""
|
||||
) as Result<HealthCheck>;
|
||||
return ffiHealthCheck(this.handle, testPath || "") as Result<HealthCheck>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { FileFinder } from "./index";
|
||||
import type { FileItem } from "./types";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { FileFinder } from "./index";
|
||||
import type { FileItem } from "./types";
|
||||
|
||||
/**
|
||||
* Integration test: full git lifecycle with a real repository.
|
||||
@@ -86,10 +86,7 @@ async function waitForFileStatus(
|
||||
}
|
||||
|
||||
/** Poll until a file is gone from the index, or the timeout is exceeded. */
|
||||
async function waitForFileGone(
|
||||
finder: FileFinder,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
async function waitForFileGone(finder: FileFinder, name: string): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
if (findFile(finder, name) === undefined) return true;
|
||||
@@ -99,10 +96,7 @@ async function waitForFileGone(
|
||||
}
|
||||
|
||||
/** Poll until the total file count reaches the expected value, or the timeout is exceeded. */
|
||||
async function waitForFileCount(
|
||||
finder: FileFinder,
|
||||
count: number,
|
||||
): Promise<number> {
|
||||
async function waitForFileCount(finder: FileFinder, count: number): Promise<number> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
const result = finder.search("", { pageSize: 200 });
|
||||
@@ -129,198 +123,189 @@ async function waitForGrep(
|
||||
return finder.liveGrep(pattern, options);
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"Git lifecycle integration",
|
||||
() => {
|
||||
let tmpDir: string;
|
||||
let finder: FileFinder;
|
||||
describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => {
|
||||
let tmpDir: string;
|
||||
let finder: FileFinder;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create temp directory and initialise a git repo with two committed files.
|
||||
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
|
||||
// that git2's resolved workdir paths match the file picker's base_path.
|
||||
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
|
||||
beforeAll(() => {
|
||||
// Create temp directory and initialise a git repo with two committed files.
|
||||
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
|
||||
// that git2's resolved workdir paths match the file picker's base_path.
|
||||
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
|
||||
|
||||
git(tmpDir, "init", "-b", "main");
|
||||
// Need at least one commit for status to work properly
|
||||
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
|
||||
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
|
||||
mkdirSync(join(tmpDir, "src"));
|
||||
writeFileSync(
|
||||
join(tmpDir, "src", "main.rs"),
|
||||
'fn main() { println?."hi"); }\n',
|
||||
);
|
||||
git(tmpDir, "add", "-A");
|
||||
git(tmpDir, "commit", "-m", "initial commit");
|
||||
git(tmpDir, "init", "-b", "main");
|
||||
// Need at least one commit for status to work properly
|
||||
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
|
||||
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
|
||||
mkdirSync(join(tmpDir, "src"));
|
||||
writeFileSync(join(tmpDir, "src", "main.rs"), 'fn main() { println?."hi"); }\n');
|
||||
git(tmpDir, "add", "-A");
|
||||
git(tmpDir, "commit", "-m", "initial commit");
|
||||
|
||||
// Create the FileFinder instance
|
||||
const result = FileFinder.create({ basePath: tmpDir });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
finder = result.value;
|
||||
// Create the FileFinder instance
|
||||
const result = FileFinder.create({ basePath: tmpDir });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
finder = result.value;
|
||||
|
||||
// Wait for the initial scan to finish
|
||||
const scanResult = finder.waitForScan(10_000);
|
||||
expect(scanResult.ok).toBe(true);
|
||||
});
|
||||
// Wait for the initial scan to finish
|
||||
const scanResult = finder.waitForScan(10_000);
|
||||
expect(scanResult.ok).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
finder?.destroy();
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
afterAll(() => {
|
||||
finder?.destroy();
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("initial scan indexes all committed files", () => {
|
||||
const result = finder.search("", { pageSize: 200 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
test("initial scan indexes all committed files", () => {
|
||||
const result = finder.search("", { pageSize: 200 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const names = result.value.items.map((i) => i.relativePath).sort();
|
||||
expect(names).toContain("hello.txt");
|
||||
expect(names).toContain("readme.md");
|
||||
expect(names).toContain("src/main.rs");
|
||||
expect(result.value.totalFiles).toBe(3);
|
||||
});
|
||||
const names = result.value.items.map((i) => i.relativePath).sort();
|
||||
expect(names).toContain("hello.txt");
|
||||
expect(names).toContain("readme.md");
|
||||
expect(names).toContain("src/main.rs");
|
||||
expect(result.value.totalFiles).toBe(3);
|
||||
});
|
||||
|
||||
test("committed files have clean git status", async () => {
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("clean");
|
||||
test("committed files have clean git status", async () => {
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("clean");
|
||||
|
||||
const main = await waitForFileStatus(finder, "main.rs", "clean");
|
||||
expect(main).toBeDefined();
|
||||
expect(main?.gitStatus).toBe("clean");
|
||||
});
|
||||
const main = await waitForFileStatus(finder, "main.rs", "clean");
|
||||
expect(main).toBeDefined();
|
||||
expect(main?.gitStatus).toBe("clean");
|
||||
});
|
||||
|
||||
test("new untracked file appears with 'untracked' status", async () => {
|
||||
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
|
||||
test("new untracked file appears with 'untracked' status", async () => {
|
||||
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
|
||||
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("untracked");
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("untracked");
|
||||
|
||||
// Total should now be 4
|
||||
const total = await waitForFileCount(finder, 4);
|
||||
expect(total).toBe(4);
|
||||
});
|
||||
// Total should now be 4
|
||||
const total = await waitForFileCount(finder, 4);
|
||||
expect(total).toBe(4);
|
||||
});
|
||||
|
||||
test("staging a new file changes status to 'staged_new'", async () => {
|
||||
git(tmpDir, "add", "new_file.ts");
|
||||
test("staging a new file changes status to 'staged_new'", async () => {
|
||||
git(tmpDir, "add", "new_file.ts");
|
||||
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("staged_new");
|
||||
});
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("staged_new");
|
||||
});
|
||||
|
||||
test("committing makes the file 'clean'", async () => {
|
||||
git(tmpDir, "commit", "-m", "add new_file");
|
||||
test("committing makes the file 'clean'", async () => {
|
||||
git(tmpDir, "commit", "-m", "add new_file");
|
||||
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("clean");
|
||||
});
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("clean");
|
||||
});
|
||||
|
||||
test("modifying a tracked file changes status to 'modified'", async () => {
|
||||
writeFileSync(
|
||||
join(tmpDir, "hello.txt"),
|
||||
"hello world\nupdated content\n",
|
||||
);
|
||||
test("modifying a tracked file changes status to 'modified'", async () => {
|
||||
writeFileSync(join(tmpDir, "hello.txt"), "hello world\nupdated content\n");
|
||||
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "modified");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("modified");
|
||||
});
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "modified");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("modified");
|
||||
});
|
||||
|
||||
test("staging a modification changes status to 'staged_modified'", async () => {
|
||||
git(tmpDir, "add", "hello.txt");
|
||||
test("staging a modification changes status to 'staged_modified'", async () => {
|
||||
git(tmpDir, "add", "hello.txt");
|
||||
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("staged_modified");
|
||||
});
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("staged_modified");
|
||||
});
|
||||
|
||||
test("committing the modification returns to 'clean'", async () => {
|
||||
git(tmpDir, "commit", "-m", "update hello");
|
||||
test("committing the modification returns to 'clean'", async () => {
|
||||
git(tmpDir, "commit", "-m", "update hello");
|
||||
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("clean");
|
||||
});
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("clean");
|
||||
});
|
||||
|
||||
test("deleting a file removes it from the index", async () => {
|
||||
unlinkSync(join(tmpDir, "new_file.ts"));
|
||||
test("deleting a file removes it from the index", async () => {
|
||||
unlinkSync(join(tmpDir, "new_file.ts"));
|
||||
|
||||
const gone = await waitForFileGone(finder, "new_file.ts");
|
||||
expect(gone).toBe(true);
|
||||
const gone = await waitForFileGone(finder, "new_file.ts");
|
||||
expect(gone).toBe(true);
|
||||
|
||||
// Total should be back to 3
|
||||
const total = await waitForFileCount(finder, 3);
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
// Total should be back to 3
|
||||
const total = await waitForFileCount(finder, 3);
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
|
||||
test("adding a file in a subdirectory works", async () => {
|
||||
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
|
||||
test("adding a file in a subdirectory works", async () => {
|
||||
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
|
||||
|
||||
const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.relativePath).toBe("src/utils.rs");
|
||||
expect(utils?.gitStatus).toBe("untracked");
|
||||
});
|
||||
const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.relativePath).toBe("src/utils.rs");
|
||||
expect(utils?.gitStatus).toBe("untracked");
|
||||
});
|
||||
|
||||
test("live grep finds content in a newly added file", async () => {
|
||||
writeFileSync(
|
||||
join(tmpDir, "src", "searchtarget.rs"),
|
||||
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
|
||||
);
|
||||
test("live grep finds content in a newly added file", async () => {
|
||||
writeFileSync(
|
||||
join(tmpDir, "src", "searchtarget.rs"),
|
||||
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
|
||||
);
|
||||
|
||||
await waitForFile(finder, "searchtarget.rs");
|
||||
await waitForFile(finder, "searchtarget.rs");
|
||||
|
||||
const result = await waitForGrep(
|
||||
finder,
|
||||
"xylophone_waterfall_97",
|
||||
{ mode: "plain" },
|
||||
(n) => n > 0,
|
||||
);
|
||||
expect(result?.ok).toBe(true);
|
||||
if (!result?.ok) return;
|
||||
const result = await waitForGrep(
|
||||
finder,
|
||||
"xylophone_waterfall_97",
|
||||
{ mode: "plain" },
|
||||
(n) => n > 0,
|
||||
);
|
||||
expect(result?.ok).toBe(true);
|
||||
if (!result?.ok) return;
|
||||
|
||||
expect(result.value.totalMatched).toBeGreaterThan(0);
|
||||
const match = result.value.items.find(
|
||||
(m) => m.relativePath === "src/searchtarget.rs",
|
||||
);
|
||||
expect(match).toBeDefined();
|
||||
expect(match!.lineContent).toContain("xylophone_waterfall_97");
|
||||
});
|
||||
expect(result.value.totalMatched).toBeGreaterThan(0);
|
||||
const match = result.value.items.find(
|
||||
(m) => m.relativePath === "src/searchtarget.rs",
|
||||
);
|
||||
expect(match).toBeDefined();
|
||||
expect(match!.lineContent).toContain("xylophone_waterfall_97");
|
||||
});
|
||||
|
||||
test("live grep no longer finds content after file is deleted", async () => {
|
||||
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
|
||||
test("live grep no longer finds content after file is deleted", async () => {
|
||||
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
|
||||
|
||||
const result = await waitForGrep(
|
||||
finder,
|
||||
"xylophone_waterfall_97",
|
||||
{ mode: "plain" },
|
||||
(n) => n === 0,
|
||||
);
|
||||
expect(result?.ok).toBe(true);
|
||||
if (!result?.ok) return;
|
||||
const result = await waitForGrep(
|
||||
finder,
|
||||
"xylophone_waterfall_97",
|
||||
{ mode: "plain" },
|
||||
(n) => n === 0,
|
||||
);
|
||||
expect(result?.ok).toBe(true);
|
||||
if (!result?.ok) return;
|
||||
|
||||
expect(result.value.totalMatched).toBe(0);
|
||||
expect(result.value.items.length).toBe(0);
|
||||
});
|
||||
expect(result.value.totalMatched).toBe(0);
|
||||
expect(result.value.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test("full add-commit cycle for subdirectory file", async () => {
|
||||
git(tmpDir, "add", "src/utils.rs");
|
||||
test("full add-commit cycle for subdirectory file", async () => {
|
||||
git(tmpDir, "add", "src/utils.rs");
|
||||
|
||||
let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.gitStatus).toBe("staged_new");
|
||||
let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.gitStatus).toBe("staged_new");
|
||||
|
||||
git(tmpDir, "commit", "-m", "add utils");
|
||||
git(tmpDir, "commit", "-m", "add utils");
|
||||
|
||||
utils = await waitForFileStatus(finder, "utils.rs", "clean");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.gitStatus).toBe("clean");
|
||||
});
|
||||
},
|
||||
);
|
||||
utils = await waitForFileStatus(finder, "utils.rs", "clean");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.gitStatus).toBe("clean");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { FileFinder } from "./index";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { findBinary, getDevBinaryPath } from "./download";
|
||||
import { getTriple, getLibExtension, getLibFilename } from "./platform";
|
||||
import { FileFinder } from "./index";
|
||||
import { getLibExtension, getLibFilename, getTriple } from "./platform";
|
||||
|
||||
// Cross-platform path normalization helpers
|
||||
const normalizePath = (path: string | null | undefined): string | null => {
|
||||
@@ -127,7 +127,7 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
if (result.value.items.length > 0) {
|
||||
// Log first few paths to see format on Windows
|
||||
// Items are strings (file paths), not objects
|
||||
const samplePaths = result.value.items
|
||||
const _samplePaths = result.value.items
|
||||
.slice(0, 3)
|
||||
.map((item) =>
|
||||
normalizePath(typeof item === "string" ? item : item.relativePath),
|
||||
@@ -180,7 +180,7 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
if (result.value.items.length > 0) {
|
||||
// Log sample match to verify content on Windows
|
||||
const first = result.value.items[0];
|
||||
const normalizedPath = normalizePath(first.relativePath);
|
||||
const _normalizedPath = normalizePath(first.relativePath);
|
||||
}
|
||||
|
||||
expect(result.value.totalMatched).toBeGreaterThan(0);
|
||||
@@ -236,9 +236,7 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
expect(result.value.filePicker.initialized).toBe(true);
|
||||
expect(result.value.filePicker.basePath).toBeDefined();
|
||||
// Normalize basePath for cross-platform comparison
|
||||
const normalizedBasePath = normalizePath(
|
||||
result.value.filePicker.basePath || "",
|
||||
);
|
||||
const normalizedBasePath = normalizePath(result.value.filePicker.basePath || "");
|
||||
const normalizedTestDir = normalizePath(testDir);
|
||||
expect(normalizedBasePath).toBe(normalizedTestDir);
|
||||
expect(typeof result.value.filePicker.indexedFiles).toBe("number");
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Track file access (for frecency)
|
||||
* finder.trackAccess("/path/to/project/src/main.ts");
|
||||
*
|
||||
* // Cleanup when done
|
||||
* finder.destroy();
|
||||
* ```
|
||||
@@ -40,44 +37,40 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Main API
|
||||
export { FileFinder } from "./finder";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Result,
|
||||
InitOptions,
|
||||
SearchOptions,
|
||||
FileItem,
|
||||
Score,
|
||||
Location,
|
||||
SearchResult,
|
||||
ScanProgress,
|
||||
HealthCheck,
|
||||
DbHealth,
|
||||
GrepMode,
|
||||
GrepOptions,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
GrepCursor,
|
||||
} from "./types";
|
||||
|
||||
// Result helpers
|
||||
export { ok, err } from "./types";
|
||||
|
||||
// Binary management (for CLI tools)
|
||||
export {
|
||||
binaryExists,
|
||||
downloadBinary,
|
||||
ensureBinary,
|
||||
binaryExists,
|
||||
getBinaryPath,
|
||||
findBinary,
|
||||
getBinaryPath,
|
||||
} from "./download";
|
||||
|
||||
// Platform utilities
|
||||
export {
|
||||
getTriple,
|
||||
getLibExtension,
|
||||
getLibFilename,
|
||||
getNpmPackageName,
|
||||
getTriple,
|
||||
} from "./platform";
|
||||
|
||||
export type {
|
||||
DbHealth,
|
||||
FileItem,
|
||||
GrepCursor,
|
||||
GrepMatch,
|
||||
GrepMode,
|
||||
GrepOptions,
|
||||
GrepResult,
|
||||
HealthCheck,
|
||||
InitOptions,
|
||||
Location,
|
||||
MultiGrepOptions,
|
||||
Result,
|
||||
ScanProgress,
|
||||
Score,
|
||||
SearchOptions,
|
||||
SearchResult,
|
||||
} from "./types";
|
||||
// Result helpers
|
||||
export { err, ok } from "./types";
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface InitOptions {
|
||||
* (default: false)
|
||||
*/
|
||||
warmupMmapCache?: boolean;
|
||||
/** enables optimizations for AI agent assistants. Provide as true if running via mcp/agent */
|
||||
aiMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,6 +218,7 @@ export interface InitOptionsInternal {
|
||||
history_db_path?: string;
|
||||
use_unsafe_no_lock: boolean;
|
||||
warmup_mmap_cache: boolean;
|
||||
ai_mode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,6 +245,7 @@ export function toInternalInitOptions(opts: InitOptions): InitOptionsInternal {
|
||||
history_db_path: opts.historyDbPath,
|
||||
use_unsafe_no_lock: opts.useUnsafeNoLock ?? false,
|
||||
warmup_mmap_cache: opts.warmupMmapCache ?? false,
|
||||
ai_mode: opts.aiMode ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,9 +253,7 @@ export function toInternalInitOptions(opts: InitOptions): InitOptionsInternal {
|
||||
* Convert public SearchOptions to internal format
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalSearchOptions(
|
||||
opts?: SearchOptions,
|
||||
): SearchOptionsInternal {
|
||||
export function toInternalSearchOptions(opts?: SearchOptions): SearchOptionsInternal {
|
||||
return {
|
||||
max_threads: opts?.maxThreads,
|
||||
current_file: opts?.currentFile,
|
||||
@@ -262,7 +264,6 @@ export function toInternalSearchOptions(
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Grep search mode
|
||||
*/
|
||||
@@ -312,6 +313,10 @@ export interface GrepOptions {
|
||||
* partial results. 0 = unlimited. (default: 0)
|
||||
*/
|
||||
timeBudgetMs?: number;
|
||||
/** Number of context lines to include before each match (default: 0) */
|
||||
beforeContext?: number;
|
||||
/** Number of context lines to include after each match (default: 0) */
|
||||
afterContext?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,6 +355,10 @@ export interface GrepMatch {
|
||||
matchRanges: [number, number][];
|
||||
/** Fuzzy match score (only in fuzzy mode) */
|
||||
fuzzyScore?: number;
|
||||
/** Lines before the match (context). Empty array when context is 0. */
|
||||
contextBefore?: string[];
|
||||
/** Lines after the match (context). Empty array when context is 0. */
|
||||
contextAfter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,6 +384,78 @@ export interface GrepResult {
|
||||
regexFallbackError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for multi-pattern grep (Aho-Corasick multi-needle search)
|
||||
*
|
||||
* Searches for lines matching ANY of the provided patterns using
|
||||
* SIMD-accelerated Aho-Corasick multi-pattern matching.
|
||||
*/
|
||||
export interface MultiGrepOptions {
|
||||
/** Patterns to search for (OR logic — matches lines containing any pattern) */
|
||||
patterns: string[];
|
||||
/** File constraints like "*.rs" or "/src/" */
|
||||
constraints?: string;
|
||||
/** Maximum file size to search in bytes (default: 10MB) */
|
||||
maxFileSize?: number;
|
||||
/** Maximum matching lines to collect from a single file (default: 0 = unlimited) */
|
||||
maxMatchesPerFile?: number;
|
||||
/** Smart case: case-insensitive when all patterns are lowercase (default: true) */
|
||||
smartCase?: boolean;
|
||||
/**
|
||||
* Pagination cursor from a previous `GrepResult.nextCursor`.
|
||||
* Omit (or pass `null`) for the first page.
|
||||
*/
|
||||
cursor?: GrepCursor | null;
|
||||
/**
|
||||
* Maximum wall-clock time in milliseconds to spend searching before returning
|
||||
* partial results. 0 = unlimited. (default: 0)
|
||||
*/
|
||||
timeBudgetMs?: number;
|
||||
/** Number of context lines to include before each match (default: 0) */
|
||||
beforeContext?: number;
|
||||
/** Number of context lines to include after each match (default: 0) */
|
||||
afterContext?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Multi-grep options format sent to Rust FFI
|
||||
* @internal
|
||||
*/
|
||||
export interface MultiGrepOptionsInternal {
|
||||
patterns: string[];
|
||||
constraints?: string;
|
||||
max_file_size?: number;
|
||||
max_matches_per_file?: number;
|
||||
smart_case?: boolean;
|
||||
file_offset?: number;
|
||||
page_limit?: number;
|
||||
time_budget_ms?: number;
|
||||
before_context?: number;
|
||||
after_context?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert public MultiGrepOptions to internal format
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalMultiGrepOptions(
|
||||
opts: MultiGrepOptions,
|
||||
pageLimit?: number,
|
||||
): MultiGrepOptionsInternal {
|
||||
return {
|
||||
patterns: opts.patterns,
|
||||
constraints: opts.constraints,
|
||||
max_file_size: opts.maxFileSize,
|
||||
max_matches_per_file: opts.maxMatchesPerFile,
|
||||
smart_case: opts.smartCase,
|
||||
file_offset: opts.cursor?._offset ?? 0,
|
||||
page_limit: pageLimit,
|
||||
time_budget_ms: opts.timeBudgetMs,
|
||||
before_context: opts.beforeContext,
|
||||
after_context: opts.afterContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Grep options format sent to Rust FFI
|
||||
* @internal
|
||||
@@ -384,21 +465,30 @@ export interface GrepOptionsInternal {
|
||||
max_matches_per_file?: number;
|
||||
smart_case?: boolean;
|
||||
file_offset?: number;
|
||||
page_limit?: number;
|
||||
mode?: string;
|
||||
time_budget_ms?: number;
|
||||
before_context?: number;
|
||||
after_context?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert public GrepOptions to internal format
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalGrepOptions(opts?: GrepOptions): GrepOptionsInternal {
|
||||
export function toInternalGrepOptions(
|
||||
opts?: GrepOptions,
|
||||
pageLimit?: number,
|
||||
): GrepOptionsInternal {
|
||||
return {
|
||||
max_file_size: opts?.maxFileSize,
|
||||
max_matches_per_file: opts?.maxMatchesPerFile,
|
||||
smart_case: opts?.smartCase,
|
||||
file_offset: opts?.cursor?._offset ?? 0,
|
||||
page_limit: pageLimit,
|
||||
mode: opts?.mode,
|
||||
time_budget_ms: opts?.timeBudgetMs,
|
||||
before_context: opts?.beforeContext,
|
||||
after_context: opts?.afterContext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FileFinder } from "./src/index";
|
||||
import { resolve, dirname } from "path";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
async function main() {
|
||||
console.log("=== fff Test Script ===\n");
|
||||
@@ -21,7 +21,9 @@ async function main() {
|
||||
if (healthBefore.ok) {
|
||||
console.log(` Version: ${healthBefore.value.version}`);
|
||||
console.log(` Git available: ${healthBefore.value.git.available}`);
|
||||
console.log(` File picker initialized: ${healthBefore.value.filePicker.initialized}`);
|
||||
console.log(
|
||||
` File picker initialized: ${healthBefore.value.filePicker.initialized}`,
|
||||
);
|
||||
} else {
|
||||
console.error(` Error: ${healthBefore.error}`);
|
||||
}
|
||||
@@ -47,7 +49,7 @@ async function main() {
|
||||
console.log("Waiting for initial scan...");
|
||||
const startTime = Date.now();
|
||||
let lastCount = 0;
|
||||
|
||||
|
||||
while (finder.isScanning()) {
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
|
||||
@@ -55,7 +57,7 @@ async function main() {
|
||||
console.log(` Scanning: ${lastCount} files...`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
|
||||
if (Date.now() - startTime > 30000) {
|
||||
console.error(" Scan timeout after 30s");
|
||||
break;
|
||||
@@ -80,7 +82,9 @@ async function main() {
|
||||
const item = searchResult.value.items[i];
|
||||
const score = searchResult.value.scores[i];
|
||||
console.log(` ${item.relativePath}`);
|
||||
console.log(` Score: ${score.total} (base: ${score.baseScore}, filename: ${score.filenameBonus})`);
|
||||
console.log(
|
||||
` Score: ${score.total} (base: ${score.baseScore}, filename: ${score.filenameBonus})`,
|
||||
);
|
||||
console.log(` Git: ${item.gitStatus}`);
|
||||
}
|
||||
} else {
|
||||
@@ -121,16 +125,18 @@ async function main() {
|
||||
if (finder2Result.ok) {
|
||||
const finder2 = finder2Result.value;
|
||||
console.log(" Second instance created successfully");
|
||||
|
||||
|
||||
finder2.waitForScan(5000);
|
||||
const search2 = finder2.search("Cargo.toml");
|
||||
if (search2.ok) {
|
||||
console.log(` Second instance found ${search2.value.totalMatched} matches for 'Cargo.toml'`);
|
||||
console.log(
|
||||
` Second instance found ${search2.value.totalMatched} matches for 'Cargo.toml'`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
finder2.destroy();
|
||||
console.log(" Second instance destroyed");
|
||||
|
||||
|
||||
// First instance should still work
|
||||
const search3 = finder.search("Cargo.toml");
|
||||
if (search3.ok) {
|
||||
|
||||
+3
-3
@@ -71,16 +71,16 @@ vim.api.nvim_create_user_command('FFFDebug', function(opts)
|
||||
local config = require('fff.conf').get()
|
||||
if opts.args == 'toggle' or opts.args == '' then
|
||||
config.debug.show_scores = not config.debug.show_scores
|
||||
config.debug.show_file_info = config.debug.show_scores
|
||||
config.debug.enabled = config.debug.show_scores
|
||||
local status = config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
elseif opts.args == 'on' then
|
||||
config.debug.show_scores = true
|
||||
config.debug.show_file_info = true
|
||||
config.debug.enabled = true
|
||||
vim.notify('FFF debug scores enabled', vim.log.levels.INFO)
|
||||
elseif opts.args == 'off' then
|
||||
config.debug.show_scores = false
|
||||
config.debug.show_file_info = false
|
||||
config.debug.enabled = false
|
||||
vim.notify('FFF debug scores disabled', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('Usage: :FFFDebug [on|off|toggle]', vim.log.levels.ERROR)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze benchmark results across all concepts and iterations."""
|
||||
import json, os, glob, sys
|
||||
|
||||
results_dir = sys.argv[1] if len(sys.argv) > 1 else "scripts/benchmark-results"
|
||||
|
||||
# Concept names (order matches benchmark-claude.sh)
|
||||
CONCEPT_NAMES = [
|
||||
"fuzzy-function-search",
|
||||
"api-endpoint-discovery",
|
||||
"cross-service-config",
|
||||
"test-file-discovery",
|
||||
"error-type-definition",
|
||||
"database-model-search",
|
||||
"auth-flow-tracing",
|
||||
"todo-tech-debt",
|
||||
"cross-language-pattern",
|
||||
"broad-pattern-search",
|
||||
]
|
||||
|
||||
def load_iter_results(concept_name, mode):
|
||||
results = []
|
||||
for i in range(1, 100):
|
||||
path = os.path.join(results_dir, f"{concept_name}-{mode}-iter{i}.json")
|
||||
if not os.path.exists(path):
|
||||
break
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if data.get("total_cost_usd", 0) > 0:
|
||||
results.append(data)
|
||||
except:
|
||||
pass
|
||||
# Also check the non-iter file as fallback
|
||||
if not results:
|
||||
path = os.path.join(results_dir, f"{concept_name}-{mode}.json")
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if data.get("total_cost_usd", 0) > 0:
|
||||
results.append(data)
|
||||
except:
|
||||
pass
|
||||
return results
|
||||
|
||||
def load_stream_trace(concept_name, mode, iteration):
|
||||
"""Load tool call trace from stream file."""
|
||||
path = os.path.join(results_dir, f"{concept_name}-{mode}-iter{iteration}.stream.jsonl")
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(results_dir, f"{concept_name}-{mode}.stream.jsonl")
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
|
||||
tool_calls = []
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except:
|
||||
continue
|
||||
if msg.get("type") == "assistant" and "message" in msg:
|
||||
for block in msg["message"].get("content", []):
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
name = block.get("name", "?")
|
||||
inp = block.get("input", {})
|
||||
summary = ""
|
||||
if "query" in inp:
|
||||
summary = inp["query"]
|
||||
elif "pattern" in inp:
|
||||
summary = inp["pattern"]
|
||||
elif "patterns" in inp:
|
||||
summary = str(inp["patterns"])
|
||||
elif "file_path" in inp:
|
||||
summary = inp["file_path"].split("/")[-1]
|
||||
elif "command" in inp:
|
||||
summary = inp["command"][:50]
|
||||
tool_calls.append({"name": name, "summary": summary})
|
||||
return tool_calls
|
||||
|
||||
|
||||
print("=" * 90)
|
||||
print(" FFF MCP vs Native — All Concepts Summary")
|
||||
print("=" * 90)
|
||||
|
||||
total_fff_cost = 0
|
||||
total_nat_cost = 0
|
||||
fff_wins = 0
|
||||
nat_wins = 0
|
||||
ties = 0
|
||||
|
||||
concept_data = []
|
||||
|
||||
for i, name in enumerate(CONCEPT_NAMES):
|
||||
fff_results = load_iter_results(name, "fff")
|
||||
nat_results = load_iter_results(name, "native")
|
||||
|
||||
if not fff_results and not nat_results:
|
||||
continue
|
||||
|
||||
fff_avg_cost = sum(r.get("total_cost_usd", 0) for r in fff_results) / max(len(fff_results), 1)
|
||||
nat_avg_cost = sum(r.get("total_cost_usd", 0) for r in nat_results) / max(len(nat_results), 1)
|
||||
fff_avg_turns = sum(r.get("num_turns", 0) for r in fff_results) / max(len(fff_results), 1)
|
||||
nat_avg_turns = sum(r.get("num_turns", 0) for r in nat_results) / max(len(nat_results), 1)
|
||||
fff_avg_wall = sum(r.get("wall_ms", 0) for r in fff_results) / max(len(fff_results), 1) / 1000
|
||||
nat_avg_wall = sum(r.get("wall_ms", 0) for r in nat_results) / max(len(nat_results), 1) / 1000
|
||||
|
||||
if fff_avg_cost < nat_avg_cost * 0.95:
|
||||
winner = "FFF"
|
||||
fff_wins += 1
|
||||
elif nat_avg_cost < fff_avg_cost * 0.95:
|
||||
winner = "NATIVE"
|
||||
nat_wins += 1
|
||||
else:
|
||||
winner = "TIE"
|
||||
ties += 1
|
||||
|
||||
total_fff_cost += fff_avg_cost
|
||||
total_nat_cost += nat_avg_cost
|
||||
|
||||
concept_data.append({
|
||||
"num": i + 1,
|
||||
"name": name,
|
||||
"fff_cost": fff_avg_cost,
|
||||
"nat_cost": nat_avg_cost,
|
||||
"fff_turns": fff_avg_turns,
|
||||
"nat_turns": nat_avg_turns,
|
||||
"fff_wall": fff_avg_wall,
|
||||
"nat_wall": nat_avg_wall,
|
||||
"fff_n": len(fff_results),
|
||||
"nat_n": len(nat_results),
|
||||
"winner": winner,
|
||||
})
|
||||
|
||||
# Print table
|
||||
print(f"\n {'#':>2} {'Concept':<28} {'FFF $':>8} {'Nat $':>8} {'FFF T':>5} {'Nat T':>5} {'FFF s':>6} {'Nat s':>6} {'N':>3} {'Winner':>8}")
|
||||
print(f" {'─'*2} {'─'*28} {'─'*8} {'─'*8} {'─'*5} {'─'*5} {'─'*6} {'─'*6} {'─'*3} {'─'*8}")
|
||||
|
||||
for d in concept_data:
|
||||
savings = (1 - d["fff_cost"] / d["nat_cost"]) * 100 if d["nat_cost"] > 0 else 0
|
||||
print(f" {d['num']:>2} {d['name']:<28} ${d['fff_cost']:.4f} ${d['nat_cost']:.4f} {d['fff_turns']:>5.1f} {d['nat_turns']:>5.1f} {d['fff_wall']:>5.0f}s {d['nat_wall']:>5.0f}s {d['fff_n']:>3} {d['winner']:>8}")
|
||||
|
||||
print(f"\n Score: FFF {fff_wins} | Native {nat_wins} | Tie {ties}")
|
||||
print(f" Total avg cost: FFF ${total_fff_cost:.4f} | Native ${total_nat_cost:.4f}")
|
||||
if total_nat_cost > 0:
|
||||
print(f" Overall savings: {(1 - total_fff_cost / total_nat_cost) * 100:+.1f}%")
|
||||
|
||||
# Show problematic concepts (where native wins by >20%)
|
||||
print(f"\n{'─' * 90}")
|
||||
print(" Concepts where fff loses (native wins by >5%):")
|
||||
for d in concept_data:
|
||||
if d["winner"] == "NATIVE":
|
||||
pct = (d["fff_cost"] / d["nat_cost"] - 1) * 100
|
||||
print(f" #{d['num']} {d['name']}: fff is {pct:+.0f}% more expensive")
|
||||
# Show tool traces for the worst iteration
|
||||
traces = load_stream_trace(d["name"], "fff", 1)
|
||||
if traces:
|
||||
print(f" fff trace: {' → '.join(t['name'].replace('mcp__fff__','') for t in traces)}")
|
||||
traces = load_stream_trace(d["name"], "native", 1)
|
||||
if traces:
|
||||
print(f" nat trace: {' → '.join(t['name'] for t in traces)}")
|
||||
|
||||
print()
|
||||
@@ -0,0 +1,816 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Benchmark: fff MCP vs Claude Code native tools on real search tasks
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/benchmark-claude.sh [concept_number] [--fff-only | --native-only]
|
||||
#
|
||||
# Runs real Claude Code instances against ~/dev/lightsource:
|
||||
# - With fff MCP tools (frecency-ranked, fuzzy search)
|
||||
# - With native tools only (Glob, Grep, Read)
|
||||
# Then compares: tokens, cost, turns, and whether the right file was found.
|
||||
#
|
||||
# Requirements:
|
||||
# - claude CLI in PATH
|
||||
# - ~/dev/lightsource exists
|
||||
# - fff MCP server built (cargo build --release, binary at target/release/fff-mcp)
|
||||
#
|
||||
# Auth: The script inherits YOUR shell environment. If you use AWS Bedrock,
|
||||
# make sure your AWS credentials are exported before running.
|
||||
# Run `claude --print -p "hello"` first to verify auth works.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
LIGHTSOURCE="$HOME/dev/lightsource"
|
||||
RESULTS_DIR="$SCRIPT_DIR/benchmark-results"
|
||||
MAX_TURNS=10
|
||||
TIMEOUT_SEC=300 # 5 min per concept per mode
|
||||
MODEL="us.anthropic.claude-opus-4-6-v1"
|
||||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
# Write MCP config to temp file to avoid shell quoting issues.
|
||||
# Both modes (fff and native) connect the fff MCP so context overhead is identical.
|
||||
FFF_MCP_FILE=$(mktemp)
|
||||
trap "rm -f $FFF_MCP_FILE" EXIT
|
||||
|
||||
cat > "$FFF_MCP_FILE" <<EOF
|
||||
{"mcpServers":{"fff":{"type":"stdio","command":"$PROJECT_ROOT/target/release/fff-mcp","args":[]}}}
|
||||
EOF
|
||||
|
||||
# ─── PREFLIGHT CHECK ──────────────────────────────────────────────────────────
|
||||
|
||||
echo "Preflight check..."
|
||||
if ! command -v claude &>/dev/null; then
|
||||
echo "ERROR: claude CLI not found in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$LIGHTSOURCE" ]]; then
|
||||
echo "ERROR: $LIGHTSOURCE does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Quick auth test — must clear nesting env vars, cd to lightsource, use </dev/null
|
||||
AUTH_TEST=$(cd "$LIGHTSOURCE" && env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT \
|
||||
timeout 60s claude --print --output-format json -p "say ok" --max-turns 1 \
|
||||
--mcp-config "$FFF_MCP_FILE" --strict-mcp-config </dev/null 2>&1 || true)
|
||||
if [[ -z "$AUTH_TEST" ]] || echo "$AUTH_TEST" | grep -q '"is_error":true' 2>/dev/null; then
|
||||
echo "ERROR: Claude auth failed. Test output:"
|
||||
echo "$AUTH_TEST" | head -5
|
||||
echo ""
|
||||
echo "If using AWS Bedrock, make sure your AWS credentials are exported:"
|
||||
echo " export AWS_ACCESS_KEY_ID=..."
|
||||
echo " export AWS_SECRET_ACCESS_KEY=..."
|
||||
echo " export AWS_SESSION_TOKEN=..."
|
||||
echo ""
|
||||
echo "Or run: aws sso login"
|
||||
exit 1
|
||||
fi
|
||||
echo " Auth OK"
|
||||
echo ""
|
||||
|
||||
# ─── 10 SEARCH CONCEPTS ───────────────────────────────────────────────────────
|
||||
|
||||
declare -a PROMPTS
|
||||
declare -a TARGETS
|
||||
declare -a NAMES
|
||||
|
||||
NAMES[1]="fuzzy-function-search"
|
||||
PROMPTS[1]="Find the function that loads metadata for an InProgressQuote in the lightsource codebase. Show me the function signature and which file it's in."
|
||||
TARGETS[1]="quotes/storage/db/src/model/quote.rs"
|
||||
|
||||
NAMES[2]="api-endpoint-discovery"
|
||||
PROMPTS[2]="Find the GraphQL mutation that handles user file uploads (the prepare upload step). Show me the function and its file path."
|
||||
TARGETS[2]="user_files_service/graphql/src/mutation.rs"
|
||||
|
||||
NAMES[3]="cross-service-config"
|
||||
PROMPTS[3]="Find where QuotesServiceClient is defined as a struct and how it's constructed. Show me the struct definition and its file."
|
||||
TARGETS[3]="quotes_service_client"
|
||||
|
||||
NAMES[4]="test-file-discovery"
|
||||
PROMPTS[4]="Find the test file for virtual expression manifests in the quotes engine. Show me the file path and list what tests are in it."
|
||||
TARGETS[4]="virtual_expression_manifest_test"
|
||||
|
||||
NAMES[5]="error-type-definition"
|
||||
PROMPTS[5]="Find where the custom Error type with variants like not_found and permission_denied is defined in the common/error crate. Show me the enum or struct definition."
|
||||
TARGETS[5]="common/error"
|
||||
|
||||
NAMES[6]="database-model-search"
|
||||
PROMPTS[6]="Find the Diesel ORM model struct for InProgressQuote — the actual struct definition with its derives, not usages. Show me the struct and its file path."
|
||||
TARGETS[6]="quotes/storage/db/src/model/quote.rs"
|
||||
|
||||
NAMES[7]="auth-flow-tracing"
|
||||
PROMPTS[7]="Find where ActorAuth is defined and trace how it's used in service GraphQL contexts. Show me the definition and one example of it being extracted in a resolver."
|
||||
TARGETS[7]="actor_auth"
|
||||
|
||||
NAMES[8]="todo-tech-debt"
|
||||
PROMPTS[8]="Find TODO comments tagged with github issues numbers (like #... or similar patterns) in the quotes-related code. Show me a few examples with their file paths."
|
||||
TARGETS[8]="TODO"
|
||||
|
||||
NAMES[9]="cross-language-pattern"
|
||||
PROMPTS[9]="Find code related to QuoteBuilder across both Rust backend and TypeScript frontend. Show me one example from each language."
|
||||
TARGETS[9]="QuoteBuilder"
|
||||
|
||||
NAMES[10]="broad-pattern-search"
|
||||
PROMPTS[10]="Find the main GraphQL query resolvers for sourcing projects — specifically the resolver that loads a single sourcing project by ID. Show me the resolver function and file."
|
||||
TARGETS[10]="sourcing_project"
|
||||
|
||||
NAMES[11]="file-by-name-lookup"
|
||||
PROMPTS[11]="What files exist in this repository related to 'quote_builder'? List 10 paths from frontend and 10 from backend."
|
||||
TARGETS[11]="quote_builder"
|
||||
|
||||
# NOTE: Concept 11 tests file-by-name lookup. The model strongly prefers native Glob
|
||||
# over find_files due to Claude Code's system prompt. find_files would be faster here
|
||||
# (fuzzy: 'tsconfig sourcing' → 1 call) but the model won't use it unprompted.
|
||||
|
||||
# ─── HELPER FUNCTIONS ──────────────────────────────────────────────────────────
|
||||
|
||||
millis() {
|
||||
python3 -c 'import time; print(int(time.time()*1000))'
|
||||
}
|
||||
|
||||
run_claude() {
|
||||
local mode="$1" # "fff" or "native"
|
||||
local concept="$2"
|
||||
local raw_prompt="${PROMPTS[$concept]}"
|
||||
local outfile="$RESULTS_DIR/${NAMES[$concept]}-${mode}.json"
|
||||
|
||||
# Both modes connect fff MCP so context overhead is identical.
|
||||
# The prompt prefix steers which tools Claude actually uses.
|
||||
local mcp_args=(--mcp-config "$FFF_MCP_FILE" --strict-mcp-config)
|
||||
# (tool_args removed — both modes use identical MCP config, prompt steers tool choice)
|
||||
|
||||
local reasoning_instruction="IMPORTANT: Before EVERY tool call, write 1-2 sentences explaining your reasoning: why you chose this specific tool, what query/pattern you picked and why, what you expect to find, and if this is a follow-up, what the previous result told you that led to this next step."
|
||||
|
||||
local prompt
|
||||
if [[ "$mode" == "fff" ]]; then
|
||||
prompt="Use fff tools (grep, find_files, multi_grep) instead of native Glob/Grep.
|
||||
|
||||
$reasoning_instruction
|
||||
|
||||
$raw_prompt"
|
||||
else
|
||||
prompt="IMPORTANT: For file search and content search, use ONLY the native tools (Glob, Grep, Read). Do NOT use any mcp__fff__* tools. Ignore the fff MCP server entirely.
|
||||
|
||||
$reasoning_instruction
|
||||
|
||||
$raw_prompt"
|
||||
fi
|
||||
|
||||
local model_args=()
|
||||
if [[ -n "$MODEL" ]]; then
|
||||
model_args=(--model "$MODEL")
|
||||
fi
|
||||
|
||||
local errfile="$RESULTS_DIR/${NAMES[$concept]}-${mode}.stderr"
|
||||
local streamfile="$RESULTS_DIR/${NAMES[$concept]}-${mode}.stream.jsonl"
|
||||
|
||||
echo " Running [$mode] concept $concept: ${NAMES[$concept]} (timeout ${TIMEOUT_SEC}s)..."
|
||||
|
||||
local start_time
|
||||
start_time=$(millis)
|
||||
|
||||
# Capture stream-json for per-turn analysis, then extract the final result.
|
||||
# IMPORTANT: </dev/null prevents stdin blocking, cd to LIGHTSOURCE so Claude's
|
||||
# tools work in the right directory.
|
||||
(
|
||||
cd "$LIGHTSOURCE"
|
||||
timeout "${TIMEOUT_SEC}s" env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT \
|
||||
claude \
|
||||
--print \
|
||||
--verbose \
|
||||
--output-format stream-json \
|
||||
--max-turns "$MAX_TURNS" \
|
||||
--max-budget-usd 0.50 \
|
||||
--dangerously-skip-permissions \
|
||||
"${model_args[@]}" \
|
||||
"${mcp_args[@]}" \
|
||||
-p "$prompt" \
|
||||
</dev/null \
|
||||
> "$streamfile" 2>"$errfile"
|
||||
) || {
|
||||
local exit_code=$?
|
||||
if [[ $exit_code -eq 124 ]]; then
|
||||
echo " TIMEOUT after ${TIMEOUT_SEC}s"
|
||||
echo "{\"type\":\"result\",\"is_error\":true,\"result\":\"TIMEOUT after ${TIMEOUT_SEC}s\",\"num_turns\":0,\"total_cost_usd\":0,\"duration_ms\":0,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}" > "$outfile"
|
||||
return
|
||||
elif [[ ! -s "$streamfile" ]]; then
|
||||
echo " FAILED (exit $exit_code)"
|
||||
local stderr_msg
|
||||
stderr_msg=$(head -3 "$errfile" 2>/dev/null | tr '\n' ' ')
|
||||
echo "{\"type\":\"result\",\"is_error\":true,\"result\":\"Process failed (exit $exit_code): $stderr_msg\",\"num_turns\":0,\"total_cost_usd\":0,\"duration_ms\":0,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}" > "$outfile"
|
||||
fi
|
||||
}
|
||||
|
||||
# Print stderr if non-empty (helps debugging)
|
||||
if [[ -s "$errfile" ]]; then
|
||||
echo " stderr: $(head -1 "$errfile")"
|
||||
fi
|
||||
|
||||
# Extract final result JSON from stream (last line with type=result)
|
||||
if [[ -s "$streamfile" ]]; then
|
||||
grep '"type":"result"' "$streamfile" | tail -1 > "$outfile" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
local end_time
|
||||
end_time=$(millis)
|
||||
local wall_ms=$(( end_time - start_time ))
|
||||
|
||||
# Inject wall time into the JSON
|
||||
if [[ -f "$outfile" ]] && [[ -s "$outfile" ]]; then
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq --argjson wall "$wall_ms" '. + {wall_ms: $wall}' "$outfile" > "$tmp" 2>/dev/null && mv "$tmp" "$outfile" || rm -f "$tmp"
|
||||
fi
|
||||
|
||||
# Quick status line
|
||||
local cost turns found_str cost_fmt
|
||||
cost=$(jq -r '.total_cost_usd // 0' "$outfile" 2>/dev/null || echo "0")
|
||||
cost_fmt=$(printf '%.4f' "$cost" 2>/dev/null || echo "$cost")
|
||||
turns=$(jq -r '.num_turns // 0' "$outfile" 2>/dev/null || echo "?")
|
||||
local err=$(jq -r '.is_error // false' "$outfile" 2>/dev/null || echo "?")
|
||||
if [[ "$err" == "true" ]]; then
|
||||
found_str="ERROR"
|
||||
else
|
||||
found_str="ok"
|
||||
fi
|
||||
echo " Done in $((wall_ms/1000))s | \$$cost_fmt | ${turns} turns | $found_str"
|
||||
|
||||
# ── Per-turn tool call analysis ──
|
||||
if [[ -s "$streamfile" ]]; then
|
||||
echo ""
|
||||
echo " ┌─ Tool call trace [$mode] ─────────────────────────────────────────"
|
||||
# Extract tool_use events and tool_result sizes from stream
|
||||
python3 - "$streamfile" <<'PYEOF'
|
||||
import json, sys, textwrap
|
||||
|
||||
stream_file = sys.argv[1]
|
||||
|
||||
# Ordered list of events: ("text", text) | ("tool", name, summary, id) | ("result", id, size)
|
||||
events = []
|
||||
result_sizes = {} # tool_use_id -> content_length
|
||||
|
||||
with open(stream_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
msg_type = msg.get("type", "")
|
||||
content_blocks = []
|
||||
|
||||
if msg_type == "assistant" and "message" in msg:
|
||||
content_blocks = msg["message"].get("content", [])
|
||||
elif msg_type == "user" and "message" in msg:
|
||||
content_blocks = msg["message"].get("content", [])
|
||||
|
||||
for block in content_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
# Assistant reasoning text
|
||||
if block.get("type") == "text" and msg_type == "assistant":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
events.append(("text", text))
|
||||
|
||||
# Tool use (Claude calling a tool)
|
||||
if block.get("type") == "tool_use":
|
||||
name = block.get("name", "?")
|
||||
inp = block.get("input", {})
|
||||
if "query" in inp:
|
||||
summary = f'query="{inp["query"]}"'
|
||||
elif "pattern" in inp:
|
||||
summary = f'pattern="{inp["pattern"]}"'
|
||||
elif "patterns" in inp:
|
||||
summary = f'patterns={json.dumps(inp["patterns"])}'
|
||||
elif "file_path" in inp:
|
||||
summary = f'file="{inp["file_path"][-60:]}"'
|
||||
elif "path" in inp:
|
||||
summary = f'path="{inp["path"][-60:]}"'
|
||||
elif "command" in inp:
|
||||
summary = f'cmd="{inp["command"][:70]}"'
|
||||
else:
|
||||
summary = str(inp)[:70]
|
||||
events.append(("tool", name, summary, block.get("id", "")))
|
||||
|
||||
# Tool result (response back from tool)
|
||||
if block.get("type") == "tool_result":
|
||||
tid = block.get("tool_use_id", "")
|
||||
content = block.get("content", "")
|
||||
if isinstance(content, list):
|
||||
total_len = sum(len(c.get("text", "")) for c in content if isinstance(c, dict))
|
||||
elif isinstance(content, str):
|
||||
total_len = len(content)
|
||||
else:
|
||||
total_len = len(str(content))
|
||||
result_sizes[tid] = total_len
|
||||
|
||||
# Print trace with reasoning
|
||||
tool_num = 0
|
||||
last_was_text = False
|
||||
for event in events:
|
||||
if event[0] == "text":
|
||||
text = event[1]
|
||||
# Truncate long reasoning, skip final answer blocks (contain code fences)
|
||||
if "```" in text:
|
||||
# Final answer with code — just show first line
|
||||
first_line = text.split("\n")[0].strip()
|
||||
if first_line:
|
||||
text = first_line[:120] + ("..." if len(first_line) > 120 else "")
|
||||
else:
|
||||
continue
|
||||
elif len(text) > 300:
|
||||
text = text[:297] + "..."
|
||||
wrapped = textwrap.wrap(text, width=90)
|
||||
if not last_was_text:
|
||||
print(" |")
|
||||
for wline in wrapped:
|
||||
print(f" | 💭 {wline}")
|
||||
last_was_text = True
|
||||
elif event[0] == "tool":
|
||||
_, name, summary, tid = event
|
||||
tool_num += 1
|
||||
rsize = result_sizes.get(tid, -1)
|
||||
size_str = f" -> {rsize:,} chars" if rsize >= 0 else ""
|
||||
print(f" | {tool_num:2d}. {name:25s} {summary[:50]:50s}{size_str}")
|
||||
last_was_text = False
|
||||
|
||||
if tool_num == 0:
|
||||
print(" | (no tool calls captured)")
|
||||
|
||||
print(f" |")
|
||||
print(f" | Total: {tool_num} tool calls")
|
||||
PYEOF
|
||||
echo " └──────────────────────────────────────────────────────────────────"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
parse_result() {
|
||||
local jsonfile="$1"
|
||||
local target="$2"
|
||||
|
||||
if [[ ! -f "$jsonfile" ]] || [[ ! -s "$jsonfile" ]]; then
|
||||
echo "0|0|0|0|false|false"
|
||||
return
|
||||
fi
|
||||
|
||||
local cost_usd num_turns duration_ms wall_ms is_error result
|
||||
cost_usd=$(jq -r '.total_cost_usd // 0' "$jsonfile" 2>/dev/null || echo "0")
|
||||
num_turns=$(jq -r '.num_turns // 0' "$jsonfile" 2>/dev/null || echo "0")
|
||||
duration_ms=$(jq -r '.duration_ms // 0' "$jsonfile" 2>/dev/null || echo "0")
|
||||
wall_ms=$(jq -r '.wall_ms // 0' "$jsonfile" 2>/dev/null || echo "0")
|
||||
is_error=$(jq -r '.is_error // false' "$jsonfile" 2>/dev/null || echo "false")
|
||||
result=$(jq -r '.result // ""' "$jsonfile" 2>/dev/null || echo "")
|
||||
|
||||
local found="false"
|
||||
if echo "$result" | grep -qi "$target" 2>/dev/null; then
|
||||
found="true"
|
||||
fi
|
||||
|
||||
echo "${cost_usd}|${num_turns}|${duration_ms}|${wall_ms}|${is_error}|${found}"
|
||||
}
|
||||
|
||||
print_comparison() {
|
||||
local concept="$1"
|
||||
local name="${NAMES[$concept]}"
|
||||
local target="${TARGETS[$concept]}"
|
||||
|
||||
local fff_file="$RESULTS_DIR/${name}-fff.json"
|
||||
local native_file="$RESULTS_DIR/${name}-native.json"
|
||||
|
||||
local fff_data native_data
|
||||
fff_data=$(parse_result "$fff_file" "$target")
|
||||
native_data=$(parse_result "$native_file" "$target")
|
||||
|
||||
IFS='|' read -r fff_cost fff_turns fff_dur fff_wall fff_err fff_found <<< "$fff_data"
|
||||
IFS='|' read -r nat_cost nat_turns nat_dur nat_wall nat_err nat_found <<< "$native_data"
|
||||
|
||||
# Token counts from usage
|
||||
local fff_input fff_output nat_input nat_output
|
||||
fff_input=$(jq -r '.usage.input_tokens // 0' "$fff_file" 2>/dev/null || echo "0")
|
||||
fff_output=$(jq -r '.usage.output_tokens // 0' "$fff_file" 2>/dev/null || echo "0")
|
||||
nat_input=$(jq -r '.usage.input_tokens // 0' "$native_file" 2>/dev/null || echo "0")
|
||||
nat_output=$(jq -r '.usage.output_tokens // 0' "$native_file" 2>/dev/null || echo "0")
|
||||
local fff_tokens=$((fff_input + fff_output))
|
||||
local nat_tokens=$((nat_input + nat_output))
|
||||
|
||||
# Determine winner
|
||||
local winner="tie"
|
||||
if [[ "$fff_found" == "true" && "$nat_found" == "false" ]]; then
|
||||
winner="FFF"
|
||||
elif [[ "$fff_found" == "false" && "$nat_found" == "true" ]]; then
|
||||
winner="NATIVE"
|
||||
elif [[ "$fff_found" == "true" && "$nat_found" == "true" ]]; then
|
||||
# Both found — compare cost with 15% tolerance band for ties
|
||||
local ratio
|
||||
ratio=$(echo "scale=4; $fff_cost / $nat_cost" | bc 2>/dev/null || echo "1")
|
||||
# ratio < 0.85 means FFF is >15% cheaper → FFF wins
|
||||
# ratio > 1.15 means FFF is >15% more expensive → NATIVE wins
|
||||
# otherwise → tie
|
||||
local ratio_x100
|
||||
ratio_x100=$(echo "$ratio * 100" | bc 2>/dev/null | cut -d. -f1 || echo "100")
|
||||
if [[ "${ratio_x100:-100}" -lt 85 ]]; then
|
||||
winner="FFF"
|
||||
elif [[ "${ratio_x100:-100}" -gt 115 ]]; then
|
||||
winner="NATIVE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Format costs to 4 decimal places
|
||||
local fff_cost_fmt nat_cost_fmt
|
||||
fff_cost_fmt=$(printf '%.4f' "$fff_cost" 2>/dev/null || echo "$fff_cost")
|
||||
nat_cost_fmt=$(printf '%.4f' "$nat_cost" 2>/dev/null || echo "$nat_cost")
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " CONCEPT $concept: $name"
|
||||
echo " Target: $target"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
printf " %-12s │ %10s │ %6s │ %8s │ %8s │ %7s │ %7s\n" "" "Cost" "Turns" "Tokens" "Wall (s)" "Found?" "Error?"
|
||||
echo " ─────────────┼────────────┼────────┼──────────┼──────────┼─────────┼────────"
|
||||
printf " %-12s │ %10s │ %6s │ %8s │ %8s │ %7s │ %7s\n" \
|
||||
"fff MCP" "\$$fff_cost_fmt" "$fff_turns" "$fff_tokens" "$((fff_wall/1000))" "$fff_found" "$fff_err"
|
||||
printf " %-12s │ %10s │ %6s │ %8s │ %8s │ %7s │ %7s\n" \
|
||||
"Native" "\$$nat_cost_fmt" "$nat_turns" "$nat_tokens" "$((nat_wall/1000))" "$nat_found" "$nat_err"
|
||||
echo " ─────────────┴────────────┴────────┴──────────┴──────────┴─────────┴────────"
|
||||
|
||||
# Cost savings percentage
|
||||
if [[ "$nat_cost" != "0" ]]; then
|
||||
local cost_savings
|
||||
cost_savings=$(echo "scale=1; (1 - $fff_cost / $nat_cost) * 100" | bc 2>/dev/null || echo "?")
|
||||
echo " Cost savings: ${cost_savings}% (fff: \$$fff_cost_fmt, native: \$$nat_cost_fmt)"
|
||||
fi
|
||||
|
||||
echo " WINNER: $winner"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── MAIN ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
SELECTED=""
|
||||
MODE="both" # both, fff-only, native-only
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--fff-only) MODE="fff"; shift ;;
|
||||
--native-only) MODE="native"; shift ;;
|
||||
--model) MODEL="$2"; shift 2 ;;
|
||||
--max-turns) MAX_TURNS="$2"; shift 2 ;;
|
||||
--timeout) TIMEOUT_SEC="$2"; shift 2 ;;
|
||||
[0-9]*) SELECTED="$1"; shift ;;
|
||||
*)
|
||||
echo "Usage: $0 [1-10] [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --fff-only Only run fff MCP (skip native)"
|
||||
echo " --native-only Only run native tools (skip fff)"
|
||||
echo " --model MODEL Use specific model (e.g., haiku, sonnet)"
|
||||
echo " --max-turns N Max agentic turns per run (default: 10)"
|
||||
echo " --timeout SEC Timeout per run in seconds (default: 300)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo " Target: ~/dev/lightsource (194K files) "
|
||||
echo " Max turns: $MAX_TURNS | Timeout: ${TIMEOUT_SEC}s | Budget: \$0.50/run"
|
||||
echo "╚════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
if [[ -n "$SELECTED" ]]; then
|
||||
concepts=("$SELECTED")
|
||||
else
|
||||
concepts=(1 2 3 4 5 6 7 8 9 10 11)
|
||||
fi
|
||||
|
||||
for c in "${concepts[@]}"; do
|
||||
echo "── Concept $c: ${NAMES[$c]} ──"
|
||||
|
||||
if [[ "$MODE" == "both" || "$MODE" == "fff" ]]; then
|
||||
run_claude "fff" "$c"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "both" || "$MODE" == "native" ]]; then
|
||||
run_claude "native" "$c"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "both" ]]; then
|
||||
print_comparison "$c"
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── FINAL ANALYSIS ──────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "$MODE" == "both" && ${#concepts[@]} -ge 3 ]]; then
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo " ANALYSIS "
|
||||
echo "╚════════════════════════════════════════════════════════════════════════════╝"
|
||||
|
||||
python3 - "$RESULTS_DIR" "${concepts[*]}" <<'ANALYSIS_EOF'
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
|
||||
results_dir = sys.argv[1]
|
||||
concepts = [int(x) for x in sys.argv[2].split()]
|
||||
|
||||
NAMES = {
|
||||
1: "fuzzy-function-search",
|
||||
2: "api-endpoint-discovery",
|
||||
3: "cross-service-config",
|
||||
4: "test-file-discovery",
|
||||
5: "error-type-definition",
|
||||
6: "database-model-search",
|
||||
7: "auth-flow-tracing",
|
||||
8: "todo-tech-debt",
|
||||
9: "cross-language-pattern",
|
||||
10: "broad-pattern-search",
|
||||
11: "file-by-name-lookup",
|
||||
}
|
||||
|
||||
def load_result(name, mode):
|
||||
path = os.path.join(results_dir, f"{name}-{mode}.json")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return None
|
||||
|
||||
def load_traces(name, mode):
|
||||
"""Extract tool calls with their input and result sizes from stream file."""
|
||||
path = os.path.join(results_dir, f"{name}-{mode}.stream.jsonl")
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
|
||||
tool_calls = []
|
||||
result_sizes = {}
|
||||
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except:
|
||||
continue
|
||||
|
||||
content_blocks = []
|
||||
msg_type = msg.get("type", "")
|
||||
if msg_type == "assistant" and "message" in msg:
|
||||
content_blocks = msg["message"].get("content", [])
|
||||
elif msg_type == "user" and "message" in msg:
|
||||
content_blocks = msg["message"].get("content", [])
|
||||
|
||||
for block in content_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "tool_use":
|
||||
inp = block.get("input", {})
|
||||
tool_calls.append({
|
||||
"name": block.get("name", "?"),
|
||||
"id": block.get("id", ""),
|
||||
"input": inp,
|
||||
"query": inp.get("query", inp.get("pattern", inp.get("patterns", inp.get("file_path", "")))),
|
||||
})
|
||||
if block.get("type") == "tool_result":
|
||||
tid = block.get("tool_use_id", "")
|
||||
content = block.get("content", "")
|
||||
if isinstance(content, list):
|
||||
total = sum(len(c.get("text", "")) for c in content if isinstance(c, dict))
|
||||
elif isinstance(content, str):
|
||||
total = len(content)
|
||||
else:
|
||||
total = len(str(content))
|
||||
result_sizes[tid] = total
|
||||
|
||||
for tc in tool_calls:
|
||||
tc["result_chars"] = result_sizes.get(tc["id"], -1)
|
||||
|
||||
return tool_calls
|
||||
|
||||
# ── Collect all data ──
|
||||
rows = []
|
||||
total_fff = 0
|
||||
total_nat = 0
|
||||
fff_wins = 0
|
||||
nat_wins = 0
|
||||
ties = 0
|
||||
|
||||
for c in concepts:
|
||||
name = NAMES.get(c, f"concept-{c}")
|
||||
fff = load_result(name, "fff")
|
||||
nat = load_result(name, "native")
|
||||
if not fff or not nat:
|
||||
continue
|
||||
|
||||
fc = fff.get("total_cost_usd", 0)
|
||||
nc = nat.get("total_cost_usd", 0)
|
||||
ft = fff.get("num_turns", 0)
|
||||
nt = nat.get("num_turns", 0)
|
||||
fw = fff.get("wall_ms", 0) / 1000
|
||||
nw = nat.get("wall_ms", 0) / 1000
|
||||
|
||||
total_fff += fc
|
||||
total_nat += nc
|
||||
|
||||
if nc > 0:
|
||||
ratio = fc / nc
|
||||
else:
|
||||
ratio = 1.0
|
||||
|
||||
if ratio < 0.85:
|
||||
winner = "FFF"
|
||||
fff_wins += 1
|
||||
elif ratio > 1.15:
|
||||
winner = "NATIVE"
|
||||
nat_wins += 1
|
||||
else:
|
||||
winner = "TIE"
|
||||
ties += 1
|
||||
|
||||
fff_traces = load_traces(name, "fff")
|
||||
nat_traces = load_traces(name, "native")
|
||||
|
||||
rows.append({
|
||||
"num": c, "name": name,
|
||||
"fff_cost": fc, "nat_cost": nc,
|
||||
"fff_turns": ft, "nat_turns": nt,
|
||||
"fff_wall": fw, "nat_wall": nw,
|
||||
"winner": winner, "ratio": ratio,
|
||||
"fff_traces": fff_traces, "nat_traces": nat_traces,
|
||||
})
|
||||
|
||||
# ── Summary table ──
|
||||
print()
|
||||
print(f" {'#':>2} {'Concept':<28} {'FFF $':>8} {'Nat $':>8} {'Δ':>6} {'FFF T':>5} {'Nat T':>5} {'Winner':>8}")
|
||||
print(f" {'─'*2} {'─'*28} {'─'*8} {'─'*8} {'─'*6} {'─'*5} {'─'*5} {'─'*8}")
|
||||
|
||||
for r in rows:
|
||||
savings = (1 - r["ratio"]) * 100
|
||||
print(f" {r['num']:>2} {r['name']:<28} ${r['fff_cost']:.4f} ${r['nat_cost']:.4f} {savings:>+5.0f}% {r['fff_turns']:>5} {r['nat_turns']:>5} {r['winner']:>8}")
|
||||
|
||||
if total_nat > 0:
|
||||
overall = (1 - total_fff / total_nat) * 100
|
||||
else:
|
||||
overall = 0
|
||||
|
||||
print()
|
||||
print(f" Score: FFF {fff_wins} | Native {nat_wins} | Tie {ties}")
|
||||
print(f" Total: FFF ${total_fff:.4f} | Native ${total_nat:.4f} | Savings: {overall:+.1f}%")
|
||||
print()
|
||||
|
||||
# ── Waste pattern analysis ──
|
||||
print(" ┌─ WASTE ANALYSIS ────────────────────────────────────────────────────")
|
||||
|
||||
for r in rows:
|
||||
traces = r["fff_traces"]
|
||||
if not traces:
|
||||
continue
|
||||
|
||||
issues = []
|
||||
|
||||
# Count tool types
|
||||
tool_search_calls = [t for t in traces if t["name"] == "ToolSearch"]
|
||||
read_calls = [t for t in traces if t["name"] == "Read"]
|
||||
grep_calls = [t for t in traces if "grep" in t["name"].lower()]
|
||||
find_calls = [t for t in traces if "find" in t["name"].lower()]
|
||||
|
||||
# Issue: ToolSearch overhead (each costs ~a turn)
|
||||
if len(tool_search_calls) >= 2:
|
||||
issues.append(f"{len(tool_search_calls)} ToolSearch calls (model loading tools in multiple turns)")
|
||||
|
||||
# Issue: Read after grep (grep didn't give enough context)
|
||||
if read_calls and grep_calls:
|
||||
read_files = set()
|
||||
for rc in read_calls:
|
||||
fp = rc.get("input", {}).get("file_path", "")
|
||||
if fp:
|
||||
read_files.add(fp.split("/")[-1])
|
||||
issues.append(f"Read calls after grep ({', '.join(read_files)}) — grep output wasn't sufficient")
|
||||
|
||||
# Issue: Many grep calls with tiny results (model is probing)
|
||||
tiny_greps = [t for t in grep_calls if 0 <= t["result_chars"] <= 50]
|
||||
if len(tiny_greps) >= 2:
|
||||
queries = [str(t["query"])[:40] for t in tiny_greps]
|
||||
issues.append(f"{len(tiny_greps)} greps returned ≤50 chars: {queries}")
|
||||
|
||||
# Issue: Large result from Read (could have been avoided)
|
||||
for rc in read_calls:
|
||||
if rc["result_chars"] > 5000:
|
||||
fn = rc.get("input", {}).get("file_path", "?").split("/")[-1]
|
||||
issues.append(f"Read({fn}) returned {rc['result_chars']:,} chars — expensive")
|
||||
|
||||
# Issue: grep returned huge result (over-broad query)
|
||||
for gc in grep_calls:
|
||||
if gc["result_chars"] > 3000:
|
||||
q = str(gc["query"])[:40]
|
||||
issues.append(f"Grep({q}) returned {gc['result_chars']:,} chars — too broad")
|
||||
|
||||
# Issue: Sequential greps that could have been multi_grep
|
||||
if len(grep_calls) >= 3 and not any("multi" in t["name"].lower() for t in traces):
|
||||
issues.append(f"{len(grep_calls)} sequential greps — could multi_grep reduce to 1 call?")
|
||||
|
||||
if issues and r["winner"] != "FFF":
|
||||
print(f" │")
|
||||
savings = (1 - r["ratio"]) * 100
|
||||
print(f" │ #{r['num']} {r['name']} ({r['winner']}, {savings:+.0f}%)")
|
||||
for issue in issues:
|
||||
print(f" │ • {issue}")
|
||||
|
||||
# Show fff trace summary
|
||||
trace_summary = " → ".join(
|
||||
t["name"].replace("mcp__fff__", "").replace("ToolSearch", "🔍")
|
||||
for t in traces
|
||||
)
|
||||
print(f" │ trace: {trace_summary}")
|
||||
|
||||
# Show native trace for comparison
|
||||
nat_traces = r["nat_traces"]
|
||||
if nat_traces:
|
||||
nat_summary = " → ".join(
|
||||
t["name"].replace("ToolSearch", "🔍")
|
||||
for t in nat_traces
|
||||
)
|
||||
print(f" │ native: {nat_summary}")
|
||||
|
||||
print(" │")
|
||||
print(" └────────────────────────────────────────────────────────────────────")
|
||||
print()
|
||||
|
||||
# ── Actionable suggestions ──
|
||||
print(" ┌─ SUGGESTED IMPROVEMENTS ────────────────────────────────────────────")
|
||||
|
||||
# Aggregate patterns across all concepts
|
||||
total_tool_search = sum(len([t for t in r["fff_traces"] if t["name"] == "ToolSearch"]) for r in rows)
|
||||
total_reads_after_grep = sum(
|
||||
1 for r in rows
|
||||
if any("grep" in t["name"].lower() for t in r["fff_traces"])
|
||||
and any(t["name"] == "Read" for t in r["fff_traces"])
|
||||
)
|
||||
total_tiny_greps = sum(
|
||||
len([t for t in r["fff_traces"] if "grep" in t["name"].lower() and 0 <= t["result_chars"] <= 50])
|
||||
for r in rows
|
||||
)
|
||||
total_sequential_greps = sum(
|
||||
len([t for t in r["fff_traces"] if "grep" in t["name"].lower()])
|
||||
for r in rows
|
||||
if len([t for t in r["fff_traces"] if "grep" in t["name"].lower()]) >= 3
|
||||
)
|
||||
|
||||
if total_reads_after_grep >= 3:
|
||||
print(f" │ 1. EXPAND GREP CONTEXT: {total_reads_after_grep}/{len(rows)} concepts do Read after grep.")
|
||||
print(f" │ Grep results need more inline context to avoid follow-up Reads.")
|
||||
print(f" │ → Increase MAX_DEF_EXPAND, show more body for non-def matches")
|
||||
print(f" │")
|
||||
|
||||
if total_tiny_greps >= 5:
|
||||
print(f" │ 2. IMPROVE ZERO/LOW-RESULT GUIDANCE: {total_tiny_greps} greps returned ≤50 chars.")
|
||||
print(f" │ When results are sparse, show related files/symbols to help the model.")
|
||||
print(f" │ → Add 'did you mean?' suggestions or sibling files in same directory")
|
||||
print(f" │")
|
||||
|
||||
if total_tool_search >= len(rows) * 1.5:
|
||||
print(f" │ 3. REDUCE TOOLSEARCH OVERHEAD: {total_tool_search} ToolSearch calls across {len(rows)} concepts.")
|
||||
print(f" │ Each ToolSearch costs a turn. Model loads tools incrementally.")
|
||||
print(f" │ → Can't fix directly, but reducing total calls makes this less impactful")
|
||||
print(f" │")
|
||||
|
||||
if total_sequential_greps >= 8:
|
||||
print(f" │ 4. PROMOTE MULTI_GREP: {total_sequential_greps} sequential grep calls could be batched.")
|
||||
print(f" │ Model uses sequential grep when multi_grep would be more efficient.")
|
||||
print(f" │ → Improve multi_grep description or auto-suggest in 0-result messages")
|
||||
print(f" │")
|
||||
|
||||
# Concept-specific suggestions
|
||||
losing = [r for r in rows if r["winner"] == "NATIVE"]
|
||||
if losing:
|
||||
print(f" │ LOSING CONCEPTS ({len(losing)}):")
|
||||
for r in losing:
|
||||
traces = r["fff_traces"]
|
||||
nat_traces = r["nat_traces"]
|
||||
fff_grep_count = len([t for t in traces if "grep" in t["name"].lower()])
|
||||
nat_grep_count = len([t for t in nat_traces if "grep" in t["name"].lower() or t["name"] == "Grep"])
|
||||
fff_read_count = len([t for t in traces if t["name"] == "Read"])
|
||||
nat_read_count = len([t for t in nat_traces if t["name"] == "Read"])
|
||||
|
||||
savings = (1 - r["ratio"]) * 100
|
||||
print(f" │ #{r['num']} {r['name']} ({savings:+.0f}%): fff={fff_grep_count}grep+{fff_read_count}read vs nat={nat_grep_count}grep+{nat_read_count}read")
|
||||
|
||||
print(" │")
|
||||
print(" └────────────────────────────────────────────────────────────────────")
|
||||
print()
|
||||
ANALYSIS_EOF
|
||||
fi
|
||||
Reference in New Issue
Block a user