Compare commits

...

4 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 736c41ecd6 feat: MCP (#272)
* feat: MCP

* chore: Update docs for - feat: MCP

* feat: Perf optimizations

* chore: Update docs for - feat: Perf optimizations
2026-03-11 21:27:11 -07:00
Dmitriy Kovalenko ac8df4c9e4 chore: Update docs for - feat: Show the actual matches for typo resistant fuzzy search 2026-03-06 13:31:12 -08:00
Dmitriy Kovalenko e3e534f4ad feat: Show the actual matches for typo resistant fuzzy search 2026-03-06 13:31:12 -08:00
Dmitriy Kovalenko 764e3ecf18 fix: Race when openning many sequenital dirs
closes https://github.com/dmtrKovalenko/fff.nvim/issues/269
closes https://github.com/dmtrKovalenko/fff.nvim/issues/256
2026-03-06 13:31:12 -08:00
76 changed files with 7370 additions and 1187 deletions
+114 -7
View File
@@ -2,7 +2,7 @@ name: Prebuild
on:
push:
branches: [main, feat/interchangable-ffi, feat/termux]
branches: [main, feat/mcp-ai]
pull_request:
jobs:
@@ -70,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
@@ -201,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
@@ -265,12 +265,98 @@ jobs:
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:
@@ -311,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: |
@@ -352,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]
@@ -445,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:
+2 -2
View File
@@ -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
@@ -62,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
+9
View File
@@ -13,3 +13,12 @@ result
big-repo/
# all the perf like utility files
*.data
node_modules/
dist/
scripts/benchmark-results/
# Native binaries (downloaded at install)
*.dylib
*.so
*.dll
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"fff": {
"type": "stdio",
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
"args": []
}
}
}
Generated
+553 -38
View File
@@ -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",
@@ -499,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"
@@ -540,8 +694,6 @@ dependencies = [
"tempfile",
"thiserror 2.0.12",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
[[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"
+2 -1
View File
@@ -2,6 +2,7 @@
members = [
"crates/fff-c",
"crates/fff-core",
"crates/fff-mcp",
"crates/fff-nvim",
"crates/fff-query-parser",
"crates/fff-searcher",
@@ -32,7 +33,7 @@ mimalloc = "0.1.47"
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"
+18 -1
View File
@@ -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
+66 -65
View File
@@ -1,9 +1,13 @@
<p align="center">
<h2 align="center">FFF.nvim</h2>
<h1 align="center">FFF</h1>
</p>
<p align="center">
Finally a smart fuzzy file picker for neovim.
<a href="#mcp"><strong>AI agents (MCP)</strong></a> &nbsp;&nbsp;|&nbsp;&nbsp; <a href="#neovim-guide"><strong>Neovim users</strong></a>
</p>
<p align="center">
<i>A fast file search for your AI and neovim, with memory built-in</i>
</p>
<p align="center" style="text-decoration: none; border: none;">
@@ -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.
![Chart showing the superiority of fff.nvim over builtin claude code tools](./chart.png)
### Prerequisites
You can install FFF as a dependency for your AI agent using a simple bash script:
FFF.nvim requires:
```bash
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
```
- Neovim 0.10.0+
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
> The installation script is here [./install-fff.sh](./install-fff.sh) if you want to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
Here is an example addition to `CLAUDE.md` that works perfectly:
```sh
# CLAUDE.md
For any file search or grep in the current git indexed directory use fff tools
```
## Neovim guide
Here is some demo on the linux repository (100k files, 8GB) but you better fill it yourself and see the magic
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
### Installation
FFF.nvim requires neovim 0.10.0 or higher
#### lazy.nvim
```lua
@@ -253,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,
@@ -282,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
@@ -302,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:
@@ -366,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:
@@ -459,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
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"files": {
"includes": ["packages/**/*.ts", "!packages/*/dist"],
"ignoreUnknown": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 90
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"complexity": {
"noForEach": "off"
}
}
}
}
+297
View File
@@ -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=="],
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+52
View File
@@ -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 -69
View File
@@ -25,9 +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 ffi_types::{
FffResult, GrepSearchOptionsJson, InitOptions, MultiGrepOptionsJson, ScanProgress,
SearchOptions,
};
use mimalloc::MiMalloc;
#[global_allocator]
@@ -109,6 +112,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 +146,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 +333,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 +348,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 +363,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 +607,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 +625,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 +634,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
+3
View File
@@ -38,6 +38,7 @@ 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 }
@@ -50,7 +51,9 @@ parking_lot = { workspace = true }
pathdiff = { workspace = true }
regex = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smartstring = { version = "1.0.1", features = ["serde"] }
toml = "0.8"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true, optional = true }
+56 -4
View File
@@ -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");
+96 -1
View File
@@ -32,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 {
@@ -44,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)
@@ -101,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),
@@ -347,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"));
}
}
+84 -18
View File
@@ -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);
@@ -748,6 +813,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;
@@ -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(())
+248 -13
View File
@@ -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,197 @@ 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 +294,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 +330,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 +341,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 +363,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 +381,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 +425,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 +503,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
View File
@@ -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")
}
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -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};
+164
View File
@@ -0,0 +1,164 @@
//! Shared logging utilities for FFF crates.
//!
//! Provides file-based tracing initialization and a panic hook that writes
//! to both stderr and a fallback log file.
use std::io;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// Install panic hook that writes to both stderr and a fallback file.
/// This is called separately from init_tracing to ensure panics are always logged.
pub fn install_panic_hook() {
PANIC_HOOK_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
// Always log to tracing (if initialized)
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF"
);
// Always print to stderr
eprintln!("=== FFF PANIC ===");
eprintln!("Message: {}", message);
eprintln!("Location: {}", location);
eprintln!("=================");
// Try to write to fallback panic log file
if let Some(cache_dir) = dirs::cache_dir() {
let panic_log = cache_dir.join("fff_panic.log");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let panic_entry = format!(
"\n[{}] PANIC at {}\nMessage: {}\n",
timestamp, location, message
);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&panic_log)
.and_then(|mut f| {
use std::io::Write;
f.write_all(panic_entry.as_bytes())
});
eprintln!("Panic logged to: {}", panic_log.display());
}
default_panic(panic_info);
}));
});
}
/// Parse a log level string into a `tracing::Level`.
///
/// Accepts "trace", "debug", "info", "warn", "error" (case-insensitive).
/// Returns `tracing::Level::INFO` for unrecognised values.
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
Some("trace") => tracing::Level::TRACE,
Some("debug") => tracing::Level::DEBUG,
Some("info") => tracing::Level::INFO,
Some("warn") => tracing::Level::WARN,
Some("error") => tracing::Level::ERROR,
_ => tracing::Level::INFO,
}
}
/// Initialize tracing with a single log file.
///
/// Creates the parent directory if it doesn't exist, truncates the log file,
/// and sets up a non-blocking file appender with structured formatting.
///
/// # Arguments
/// * `log_file_path` - Full path to the log file
/// * `log_level` - Log level (trace, debug, info, warn, error)
///
/// # Returns
/// * `Result<String, io::Error>` - Full path to the log file on success
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
// Install panic hook first (does nothing if already installed)
install_panic_hook();
let log_path = Path::new(log_file_path);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file_appender = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true) // creates a new file on every setup
.open(log_path)?;
let level = parse_log_level(log_level);
TRACING_INITIALIZED.get_or_init(|| {
let (non_blocking_appender, guard) = non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
.with_default_directive(level.into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF tracing initialized with log file: {}",
log_path.display()
);
}
guard
});
Ok(log_file_path.to_string())
}
+1
View File
@@ -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);
+25 -7
View File
@@ -172,7 +172,6 @@ pub fn match_and_score_files<'a>(
let query_contains_path_separator = fuzzy_parts.iter().any(|p| p.contains(MAIN_SEPARATOR));
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(context.max_typos),
sort: false,
scoring: Scoring {
@@ -230,6 +229,14 @@ pub fn match_and_score_files<'a>(
let mut base_score = path_match.score as i32;
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
// Give modified/dirty files a 15% boost to make them appear higher in results
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
base_score * 15 / 100
} else {
0
};
let distance_penalty =
calculate_distance_penalty(context.current_file, &file.relative_path);
@@ -296,6 +303,7 @@ pub fn match_and_score_files<'a>(
let total = base_score
.saturating_add(frecency_boost)
.saturating_add(git_status_boost)
.saturating_add(distance_penalty)
.saturating_add(filename_bonus)
.saturating_add(current_file_penalty)
@@ -312,6 +320,7 @@ pub fn match_and_score_files<'a>(
0
},
frecency_boost,
git_status_boost,
distance_penalty,
combo_match_boost,
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
@@ -363,9 +372,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 +394,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 +504,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 +522,7 @@ mod tests {
special_filename_bonus: 0,
current_file_penalty: 0,
frecency_boost: 0,
git_status_boost: 0,
exact_match: false,
match_type: "test",
combo_match_boost: 0,
@@ -568,7 +588,7 @@ mod tests {
#[test]
fn test_partial_sort_with_same_scores() {
// Test tiebreaker with modified time
let test_data = vec![
let test_data = [
create_test_file("file1.rs", 100, 5000), // Same score, older
create_test_file("file2.rs", 100, 8000), // Same score, newer
create_test_file("file3.rs", 100, 3000), // Same score, oldest
@@ -617,7 +637,7 @@ mod tests {
#[test]
fn test_no_partial_sort_for_small_results() {
// When results.len() <= threshold, should use regular sort
let test_data = vec![
let test_data = [
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
@@ -666,7 +686,6 @@ mod multi_part_tests {
// Test with max_typos = 2 (safe for short needles)
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(2),
sort: false,
..Default::default()
@@ -698,7 +717,6 @@ mod multi_part_tests {
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs".to_lowercase();
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(2),
sort: false,
..Default::default()
+1
View File
@@ -155,6 +155,7 @@ pub struct Score {
pub filename_bonus: i32,
pub special_filename_bonus: i32,
pub frecency_boost: i32,
pub git_status_boost: i32,
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
+40 -10
View File
@@ -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, &regex_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 {
+27
View File
@@ -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"] }
+15
View File
@@ -0,0 +1,15 @@
fn main() {
// Embed the git commit hash at build time for update checking.
let hash = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=FFF_GIT_HASH={}", hash);
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/refs/");
}
+52
View File
@@ -0,0 +1,52 @@
//! Cursor store for grep pagination.
//!
//! Maintains an in-memory map of opaque cursor IDs to file offsets.
//! Cursors are evicted LRU-style when the store exceeds capacity.
use std::collections::{HashMap, VecDeque};
const MAX_CURSORS: usize = 20;
/// Stores cursor state for paginated grep results.
pub struct CursorStore {
counter: u64,
/// Map from cursor ID string → file offset for next page.
cursors: HashMap<String, usize>,
/// Insertion order for LRU eviction.
insertion_order: VecDeque<String>,
}
impl CursorStore {
pub fn new() -> Self {
Self {
counter: 0,
cursors: HashMap::new(),
insertion_order: VecDeque::new(),
}
}
/// Store a cursor and return its opaque ID string.
pub fn store(&mut self, file_offset: usize) -> String {
self.counter = self.counter.wrapping_add(1);
let id = self.counter.to_string();
self.cursors.insert(id.clone(), file_offset);
self.insertion_order.push_back(id.clone());
// Evict oldest cursors
while self.cursors.len() > MAX_CURSORS {
if let Some(oldest) = self.insertion_order.pop_front() {
self.cursors.remove(&oldest);
} else {
break;
}
}
id
}
/// Retrieve the file offset for a cursor ID.
pub fn get(&self, id: &str) -> Option<usize> {
self.cursors.get(id).copied()
}
}
+285
View File
@@ -0,0 +1,285 @@
//! 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 mimalloc::MiMalloc;
use rmcp::{ServiceExt, transport::stdio};
use server::FffServer;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
pub const MCP_INSTRUCTIONS: &str = concat!(
"FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n",
"\n",
"## Which Tool Should I Use?\n",
"\n",
"- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n",
"- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n",
"- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n",
"\n",
"## Core Rules\n",
"\n",
"### 1. Search BARE IDENTIFIERS only\n",
"Grep matches single lines. Search for ONE identifier per query:\n",
" + 'InProgressQuote' -> finds definition + all usages\n",
" + 'ActorAuth' -> finds enum, struct, all call sites\n",
" x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n",
" x 'ctx.data::<ActorAuth>' -> code syntax, too specific, 0 results\n",
" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n",
" x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n",
"\n",
"### 2. NEVER use regex unless you truly need alternation\n",
"Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n",
"If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n",
"\n",
"### 3. Stop searching after 2 greps -- READ the code\n",
"After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n",
"Do NOT keep grepping with variations. More greps != better understanding.\n",
"\n",
"### 4. Use multi_grep for multiple identifiers\n",
"When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n",
" + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n",
" x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n",
"\n",
"## Workflow\n",
"\n",
"**Have a specific name?** -> grep the bare identifier.\n",
"**Need multiple name variants?** -> multi_grep with all variants in one call.\n",
"**Exploring a topic / finding files?** -> find_files.\n",
"**Got results?** -> Read the top file. Don't grep again.\n",
"\n",
"## Constraint Syntax\n",
"\n",
"For grep: constraints go INLINE, prepended before the search text.\n",
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
"\n",
"Constraints MUST match one of these formats:\n",
" Extension: '*.rs', '*.{ts,tsx}' (starts with *.)\n",
" Directory: 'src/', 'quotes/' (ends with /)\n",
" Exclude: '!test/', '!*.spec.ts' (starts with !)\n",
"\n",
"! Bare words are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
"\n",
"Prefer broad constraints:\n",
" + '*.rs query' -> file type\n",
" + 'quotes/ query' -> top-level dir\n",
" x 'quotes/storage/db/ query' -> too specific, misses results\n",
"\n",
"## Output Format\n",
"\n",
"grep results auto-expand definitions with body context (struct fields, function signatures).\n",
"This often provides enough information WITHOUT a follow-up Read call.\n",
"Lines marked with | are definition body context. [def] marks definition files.\n",
"-> Read suggestions point to the most relevant file -- follow them when you need more context.\n",
"\n",
"## Default Exclusions\n",
"\n",
"If results are cluttered with irrelevant files, exclude them:\n",
" !tests/ - exclude tests directory\n",
" !*.spec.ts - exclude test files\n",
" !generated/ - exclude generated code",
);
/// FFF MCP Server — high-performance file finder for AI code assistants.
#[derive(Parser)]
#[command(name = "fff-mcp", version = env!("CARGO_PKG_VERSION"))]
struct Args {
/// Base directory to index. Defaults to the current working directory.
#[arg(value_name = "PATH")]
base_path: Option<String>,
/// Path to the frecency database.
#[arg(long = "frecency-db")]
frecency_db_path: Option<String>,
/// Path to the query history database.
#[arg(long = "history-db")]
#[allow(dead_code)]
history_db_path: Option<String>,
/// Path to the log file.
#[arg(long = "log-file")]
log_file: Option<String>,
/// Log level (e.g. trace, debug, info, warn, error).
#[arg(long = "log-level")]
log_level: Option<String>,
/// Disable automatic update checks on startup.
#[arg(long = "no-update-check")]
no_update_check: bool,
}
/// Resolve default paths for frecency db, history db, and log file.
/// Shares Neovim's standard data locations when they exist so the MCP
/// server and fff.nvim plugin use the same databases.
fn resolve_defaults(args: &mut Args) {
let home = dirs_home();
let is_windows = cfg!(target_os = "windows");
let nvim_cache_dir = if is_windows {
format!("{}\\AppData\\Local\\nvim-data", home)
} else {
format!("{}/.cache/nvim", home)
};
let nvim_data_dir = if is_windows {
format!("{}\\AppData\\Local\\nvim-data", home)
} else {
format!("{}/.local/share/nvim", home)
};
let use_nvim_paths = std::path::Path::new(&nvim_cache_dir).exists()
|| std::path::Path::new(&nvim_data_dir).exists();
if args.frecency_db_path.is_none() {
args.frecency_db_path = Some(if use_nvim_paths {
format!("{}/fff_nvim", nvim_cache_dir)
} else {
format!("{}/.fff/frecency.mdb", home)
});
}
if args.history_db_path.is_none() {
args.history_db_path = Some(if use_nvim_paths {
format!("{}/fff_queries", nvim_data_dir)
} else {
format!("{}/.fff/history.mdb", home)
});
}
// Ensure parent directories exist for database paths
for path in [&args.frecency_db_path, &args.history_db_path]
.into_iter()
.flatten()
{
if let Some(parent) = std::path::Path::new(path).parent() {
let _ = std::fs::create_dir_all(parent);
}
}
if args.log_file.is_none() {
args.log_file = Some(if is_windows {
format!("{}\\AppData\\Local\\fff_mcp.log", home)
} else {
format!("{}/.cache/fff_mcp.log", home)
});
}
}
fn dirs_home() -> String {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = Args::parse();
resolve_defaults(&mut args);
let log_file = args.log_file.as_deref().unwrap_or("");
if let Err(e) = fff_core::log::init_tracing(log_file, args.log_level.as_deref()) {
eprintln!("Warning: Failed to init tracing: {}", e);
}
let base_path = args.base_path.unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
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(())
}
+596
View File
@@ -0,0 +1,596 @@
//! Output formatting for MCP grep/search results.
//!
//! Port of `packages/fff-mcp/src/output.ts` — token-efficient formatting
//! with definition auto-expansion, frecency/git annotations, and Read suggestions.
use fff_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
}
}
+691
View File
@@ -0,0 +1,691 @@
//! FFF MCP server — tool definitions and handlers.
//!
//! Uses the `rmcp` crate's `#[tool_router]` / `#[tool_handler]` macros
//! for declarative tool registration. Each tool method directly calls
//! `fff-core` APIs (no C FFI overhead).
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use fff_core::file_picker::FilePicker;
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{Constraint, FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
use crate::cursor::CursorStore;
use crate::output::{GrepFormatter, OutputMode, file_suffix};
/// Strip common delimiters for fuzzy fallback queries.
fn strip_delimiters(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if !matches!(c, ':' | '-' | '_') {
out.push(c);
}
}
out
}
/// Compute grep search options from output mode and context settings.
fn make_grep_options(
output_mode: OutputMode,
mode: GrepMode,
file_offset: usize,
context: Option<usize>,
) -> (GrepSearchOptions, bool) {
let is_usage = output_mode == OutputMode::Usage;
let matches_per_file = match output_mode {
OutputMode::FilesWithMatches => 1,
_ if is_usage => 8,
_ => 10,
};
let ctx_lines = if is_usage {
context.unwrap_or(1)
} else {
context.unwrap_or(0)
};
let auto_expand = !is_usage && ctx_lines == 0;
let after_ctx = if auto_expand { 8 } else { ctx_lines };
(
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: matches_per_file,
smart_case: true,
file_offset,
page_limit: 50,
mode,
time_budget_ms: 0,
before_context: ctx_lines,
after_context: after_ctx,
classify_definitions: true,
},
auto_expand,
)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FindFilesParams {
/// Fuzzy search query. Supports path prefixes and glob constraints.
pub query: String,
/// Max results (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
/// Cursor from previous result. Only use if previous results weren't sufficient.
pub cursor: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GrepParams {
/// Search text or regex query with optional constraint prefixes.
/// Matches within single lines only — use ONE specific term, not multiple words.
pub query: String,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
/// Cursor from previous result. Only use if previous results weren't sufficient.
pub cursor: Option<String>,
/// Output format (default 'content').
pub output_mode: Option<String>,
}
fn deserialize_patterns<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct PatternsVisitor;
impl<'de> de::Visitor<'de> for PatternsVisitor {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string, an array of strings, or a stringified JSON array")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
// Try to parse as JSON array first
if v.starts_with('[')
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(v)
{
return Ok(parsed);
}
Ok(vec![v.to_string()])
}
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
if v.starts_with('[')
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(&v)
{
return Ok(parsed);
}
Ok(vec![v])
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut values = Vec::new();
while let Some(value) = seq.next_element::<String>()? {
values.push(value);
}
Ok(values)
}
}
deserializer.deserialize_any(PatternsVisitor)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct MultiGrepParams {
/// Patterns to match (OR logic). Include all naming conventions: snake_case, PascalCase, camelCase.
#[serde(deserialize_with = "deserialize_patterns")]
pub patterns: Vec<String>,
/// File constraints (e.g. '*.{ts,tsx} !test/'). ALWAYS provide when possible.
pub constraints: Option<String>,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
pub max_results: Option<usize>,
/// Cursor from previous result.
pub cursor: Option<String>,
/// Output format (default 'content').
pub output_mode: Option<String>,
/// Context lines before/after each match.
pub context: Option<usize>,
}
#[derive(Clone)]
pub struct FffServer {
picker: SharedPicker,
#[allow(dead_code)]
frecency: SharedFrecency,
cursor_store: Arc<Mutex<CursorStore>>,
update_notice_sent: Arc<AtomicBool>,
tool_router: ToolRouter<Self>,
}
impl FffServer {
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
Self {
picker,
frecency,
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
update_notice_sent: Arc::new(AtomicBool::new(false)),
tool_router: Self::tool_router(),
}
}
/// Wait for the initial file scan to complete.
#[allow(dead_code)]
pub fn wait_for_scan(&self) {
loop {
let guard = self.picker.read().ok();
let is_scanning = guard
.as_ref()
.and_then(|g| g.as_ref())
.map(|p| p.is_scan_active())
.unwrap_or(true);
if !is_scanning {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
/// Lock the cursor store, returning an MCP error on poisoned mutex.
fn lock_cursors(&self) -> Result<std::sync::MutexGuard<'_, CursorStore>, ErrorData> {
self.cursor_store.lock().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire cursor store lock: {e}"), None)
})
}
/// If an update notice is available and hasn't been sent yet, append it
/// to the tool result. Called once per server lifetime (first tool call).
fn maybe_append_update_notice(&self, result: &mut CallToolResult) {
if self.update_notice_sent.swap(true, Ordering::Relaxed) {
return;
}
let notice = crate::update_check::get_update_notice();
if notice.is_empty() {
// Reset so the next call can try again (check may still be in flight)
self.update_notice_sent.store(false, Ordering::Relaxed);
return;
}
result.content.push(Content::text(notice));
}
/// Perform grep with auto-retry logic.
///
/// Acquires the picker read-lock once and holds it for the entire
/// operation, so `GrepResult` references are used directly — no cloning.
/// Always uses AI query parsing since this is an MCP server for AI agents.
fn perform_grep(
&self,
query: &str,
mode: GrepMode,
max_results: usize,
cursor_id: Option<&str>,
output_mode: OutputMode,
context: Option<usize>,
) -> Result<CallToolResult, ErrorData> {
let file_offset = cursor_id
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let (options, auto_expand) = make_grep_options(output_mode, mode, file_offset, context);
let ctx_lines = options.before_context;
// Acquire picker lock once for the entire operation.
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let files = picker.get_files();
let parser = QueryParser::new(AiGrepConfig);
let parsed = parser.parse(query);
let result = grep::grep_search(files, query, parsed.as_ref(), &options);
if result.matches.is_empty() && file_offset == 0 {
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
let parts: Vec<&str> = query.split_whitespace().collect();
if parts.len() >= 2 {
let first_word = parts[0];
let is_valid_constraint = first_word.starts_with('!')
|| first_word.starts_with('*')
|| first_word.ends_with('/');
if !is_valid_constraint {
let rest_query = parts[1..].join(" ");
let rest_parsed = parser.parse(&rest_query);
let rest_text: Cow<str> = rest_parsed
.as_ref()
.map(|p| Cow::Owned(p.grep_text()))
.unwrap_or(Cow::Borrowed(&rest_query));
let retry_mode = if has_regex_metacharacters(&rest_text) {
GrepMode::Regex
} else {
mode
};
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
let retry_result =
grep::grep_search(files, &rest_query, rest_parsed.as_ref(), &retry_options);
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &retry_result.matches,
files: &retry_result.files,
total_matched: retry_result.matches.len(),
next_file_offset: retry_result.next_file_offset,
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
}
.format(&mut cs);
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 matches for '{}'. Auto-broadened to '{}':\n{}",
query, rest_query, text
))]));
}
}
}
// Fuzzy fallback for typo tolerance
let fuzzy_query = strip_delimiters(&query.to_lowercase());
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
let fuzzy_parsed = parser.parse(&fuzzy_query);
let fuzzy_result =
grep::grep_search(files, &fuzzy_query, fuzzy_parsed.as_ref(), &fuzzy_options);
if !fuzzy_result.matches.is_empty() {
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"0 exact matches. {} approximate:",
fuzzy_result.matches.len()
));
let mut current_file = "";
for m in fuzzy_result.matches.iter().take(3) {
let file = fuzzy_result.files[m.file_index];
if file.relative_path.as_str() != current_file {
current_file = &file.relative_path;
lines.push(current_file.to_string());
}
lines.push(format!(" {}: {}", m.line_number, m.line_content));
}
return Ok(CallToolResult::success(vec![Content::text(
lines.join("\n"),
)]));
}
let hint = match &parsed {
Some(q)
if q.constraints
.iter()
.any(|c| matches!(c, Constraint::FilePath(_))) =>
{
let path = q
.constraints
.iter()
.find_map(|c| match c {
Constraint::FilePath(p) => Some(*p),
_ => None,
})
.unwrap();
let ext = path.rsplit('.').next().unwrap_or("");
format!(
" Constraint '{path}' looks like a file path — use Read to search in a specific file, or '*.{ext}' for extension filter."
)
}
Some(q) if !q.constraints.is_empty() && !q.grep_text().is_empty() => {
" Try to omit constraint".to_string()
}
_ => String::new(),
};
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 matches {}",
hint
))]));
}
if result.matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
let mut cs = self.lock_cursors()?;
let text = &GrepFormatter {
matches: &result.matches,
files: &result.files,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
auto_expand_defs: auto_expand,
}
.format(&mut cs);
Ok(CallToolResult::success(vec![Content::text(text)]))
}
}
#[tool_router]
impl FffServer {
/// Fuzzy file search by name. Searches FILE NAMES, not file contents.
/// Use it when you need to find a file, not a definition.
/// Use grep instead for searching code content (definitions, usage patterns).
/// Supports fuzzy matching, path prefixes ('shc/'), and glob constraints.
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
#[tool(
name = "find_files",
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
)]
fn find_files(
&self,
Parameters(params): Parameters<FindFilesParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20);
let query = &params.query;
let page_offset = params
.cursor
.as_deref()
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let files = picker.get_files();
let base_path = picker.base_path();
let make_opts = |offset: usize| FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(base_path),
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(&params.query);
let grep_text: Cow<str> = parsed
.as_ref()
.map(|p| Cow::Owned(p.grep_text()))
.unwrap_or(Cow::Borrowed(&params.query));
let mode = if has_regex_metacharacters(&grep_text) {
GrepMode::Regex
} else {
GrepMode::PlainText
};
let mut result = self.perform_grep(
&params.query,
mode,
max_results,
params.cursor.as_deref(),
output_mode,
None,
)?;
self.maybe_append_update_notice(&mut result);
Ok(result)
}
/// Search file contents for lines matching ANY of multiple patterns (OR logic).
/// Patterns are literal text — NEVER escape special characters.
#[tool(
name = "multi_grep",
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax."
)]
fn multi_grep(
&self,
Parameters(params): Parameters<MultiGrepParams>,
) -> Result<CallToolResult, ErrorData> {
let mut result = self.multi_grep_inner(params)?;
self.maybe_append_update_notice(&mut result);
Ok(result)
}
}
impl FffServer {
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20);
let output_mode = OutputMode::new(params.output_mode.as_deref());
let file_offset = params
.cursor
.as_deref()
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
.unwrap_or(0);
let (options, auto_expand) = make_grep_options(
output_mode,
GrepMode::PlainText,
file_offset,
params.context,
);
let ctx_lines = options.before_context;
let constraint_query = params.constraints.as_deref().unwrap_or("");
let guard = self.picker.read().map_err(|e| {
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
})?;
let picker = guard
.as_ref()
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
let parsed_constraints = 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 &params.patterns {
let full_query: Cow<str> = if !constraint_query.is_empty() {
Cow::Owned(format!("{} {}", constraint_query, pat))
} else {
Cow::Borrowed(pat)
};
let parsed = parser.parse(&full_query);
let fb_result =
grep::grep_search(files, &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)
}
}
+118
View File
@@ -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"
);
}
}
}
-2
View File
@@ -65,8 +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"] }
[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;
+4 -1
View File
@@ -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!("══════════════════════════════════════════════════════════════");
+96 -9
View File
@@ -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}",
+33 -2
View File
@@ -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)
}
+2 -1
View File
@@ -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),
)?;
+2 -1
View File
@@ -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),
)
+2 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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};
+129 -1
View File
@@ -1,6 +1,47 @@
use crate::constraints::Constraint;
use crate::glob_detect::has_wildcards;
/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
///
/// A token is a filename/path if ALL of:
/// - Does NOT end with `/` (that's a directory/PathSegment)
/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
/// - Last component (after final `/`) contains `.` with a valid-looking extension
/// (110 alphanumeric chars starting with a letter, e.g. `rs`, `json`, `tsx`)
///
/// This covers both bare filenames (`score.rs`) and path-prefixed ones (`src/main.rs`).
#[inline]
fn is_filename_constraint_token(token: &str) -> bool {
let bytes = token.as_bytes();
// Must NOT end with / (that's a PathSegment)
if bytes.last() == Some(&b'/') {
return false;
}
// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}
// Get the filename component (after last /)
let filename = token.rsplit('/').next().unwrap_or(token);
// Extension must exist and look like a real file extension:
// starts with an ASCII letter (rejects version numbers like "v2.0"),
// followed by alphanumeric chars, max 10 chars total.
match filename.rfind('.') {
Some(dot_pos) => {
let ext = &filename[dot_pos + 1..];
!ext.is_empty()
&& ext.len() <= 10
&& ext.as_bytes()[0].is_ascii_alphabetic()
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
}
None => false,
}
}
/// Parser configuration trait - allows different picker types to customize parsing
pub trait ParserConfig {
fn enable_glob(&self) -> bool {
@@ -54,7 +95,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
@@ -107,3 +157,81 @@ impl ParserConfig for GrepConfig {
false
}
}
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
/// automatic file-path constraint detection.
///
/// When an AI agent sends `"libswscale/input.c rgba32ToY"`, the token
/// `libswscale/input.c` is detected as a `FilePath` constraint so the
/// search is scoped to that file. The caller validates the constraint
/// against the index and drops it if no files match (fallback).
#[derive(Debug, Clone, Copy, Default)]
pub struct AiGrepConfig;
impl ParserConfig for AiGrepConfig {
fn enable_path_segments(&self) -> bool {
true
}
fn enable_git_status(&self) -> bool {
false
}
fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
return true;
}
// AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
// Recognise tokens that start AND end with `*` with non-empty text
// between them as glob constraints. Bare `*` or `**` are excluded.
if !has_wildcards(token) {
return false;
}
let bytes = token.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'*'
&& bytes[bytes.len() - 1] == b'*'
&& bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
{
return true;
}
false
}
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_file_path_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
#[inline]
fn is_file_path_token(token: &str) -> bool {
let bytes = token.as_bytes();
// Must contain at least one /
if !bytes.contains(&b'/') {
return false;
}
// Must NOT end with / (that's a PathSegment)
if bytes.last() == Some(&b'/') {
return false;
}
// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}
// Last component must contain . (file extension)
match token.rsplit('/').next() {
Some(last) => last.contains('.'),
None => false,
}
}
@@ -22,6 +22,10 @@ pub enum Constraint<'a> {
/// Path constraint: /src/ -> PathSegment("src")
PathSegment(&'a str),
/// File path constraint (AI mode): "libswscale/input.c" → FilePath("libswscale/input.c")
/// Matches files whose relative path ends with this suffix at a `/` boundary.
FilePath(&'a str),
/// File type constraint: type:rust -> FileType("rust")
FileType(&'a str),
+1 -1
View File
@@ -45,7 +45,7 @@ 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};
+362 -15
View File
@@ -44,12 +44,17 @@ impl<C: ParserConfig> QueryParser<C> {
if whitespace_count == 0 {
// Try to parse as constraint first
if let Some(constraint) = parse_token(query, config) {
constraints.push(constraint);
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Empty,
location: None,
});
// Don't treat filename tokens (FilePath) as constraints in single-token
// queries — the user is fuzzy-searching, not filtering. FilePath constraints
// are only useful as filters in multi-token queries like "score.rs search".
if !matches!(constraint, Constraint::FilePath(_)) {
constraints.push(constraint);
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Empty,
location: None,
});
}
}
// Try to extract location from single token (e.g., "file:12")
@@ -69,8 +74,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);
}
@@ -145,15 +162,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> {
@@ -341,11 +365,12 @@ fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
}
/// Parse path segment with trailing slash: www/ -> PathSegment("www")
/// Also supports multi-segment paths: libswscale/aarch64/ -> PathSegment("libswscale/aarch64")
#[inline]
fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
if token.len() > 1 && token.ends_with('/') {
let segment = token.trim_end_matches('/');
if !segment.is_empty() && !segment.contains('/') {
if !segment.is_empty() {
Some(Constraint::PathSegment(segment))
} else {
None
@@ -428,8 +453,15 @@ mod tests {
parse_path_segment_trailing("src/"),
Some(Constraint::PathSegment("src"))
);
// Should not match paths with multiple segments
assert_eq!(parse_path_segment_trailing("src/lib/"), None);
// Multi-segment paths should work
assert_eq!(
parse_path_segment_trailing("src/lib/"),
Some(Constraint::PathSegment("src/lib"))
);
assert_eq!(
parse_path_segment_trailing("libswscale/aarch64/"),
Some(Constraint::PathSegment("libswscale/aarch64"))
);
// Should not match without trailing slash
assert_eq!(parse_path_segment_trailing("www"), None);
}
@@ -716,6 +748,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 +868,268 @@ 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_no_false_positive_no_slash() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("main.rs pattern").expect("Should parse");
// No slash → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "main.rs pattern");
}
#[test]
fn test_ai_grep_no_false_positive_no_extension() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/utils pattern").expect("Should parse");
// No extension in last component → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "src/utils pattern");
}
#[test]
fn test_ai_grep_wildcard_not_filepath() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/**/*.rs pattern").expect("Should parse");
// Contains wildcards → should be a Glob, not FilePath
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
"Expected Glob, got {:?}",
result.constraints[0]
);
}
#[test]
fn test_ai_grep_star_text_star_is_glob() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("*quote* TODO").expect("Should parse");
// `*quote*` should be recognised as a glob constraint in AI mode
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::Glob("*quote*")),
"Expected Glob(*quote*), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
}
#[test]
fn test_ai_grep_bare_star_not_glob() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("* pattern").expect("Should parse");
// Bare `*` should NOT be treated as a glob (too broad)
assert!(
result.constraints.is_empty(),
"Expected no constraints, got {:?}",
result.constraints
);
}
#[test]
fn test_grep_config_star_text_star_not_glob() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser.parse("*quote* TODO").expect("Should parse");
// Regular grep mode should NOT treat `*quote*` as a glob
assert!(
result.constraints.is_empty(),
"Expected no constraints in GrepConfig, got {:?}",
result.constraints
);
}
// ── File picker filename constraint tests ─────────────────────────
#[test]
fn test_file_picker_bare_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("score.rs file_picker")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("score.rs")),
"Expected FilePath(\"score.rs\"), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
}
#[test]
fn test_file_picker_path_prefixed_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("libswscale/slice.c lum_convert")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
result.constraints[0],
Constraint::FilePath("libswscale/slice.c")
),
"Expected FilePath(\"libswscale/slice.c\"), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
}
#[test]
fn test_file_picker_single_token_filename_stays_fuzzy() {
let parser = QueryParser::new(FilePickerConfig);
// Single-token filename should NOT become a constraint — it should
// return None so the caller uses the raw query for fuzzy matching.
let result = parser.parse("score.rs");
assert!(
result.is_none(),
"Single-token filename should return None (fuzzy match), got {:?}",
result
);
}
#[test]
fn test_file_picker_filename_with_multiple_fuzzy_parts() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("main.rs src components")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("main.rs")
));
assert_eq!(
result.fuzzy_query,
FuzzyQuery::Parts(smallvec::smallvec!["src", "components"])
);
}
#[test]
fn test_file_picker_version_number_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("v2.0 release")
.expect("Should parse multi-token query");
// v2.0 extension starts with digit → not a filename constraint
assert!(
result.constraints.is_empty(),
"v2.0 should not be a FilePath constraint, got {:?}",
result.constraints
);
}
#[test]
fn test_file_picker_only_one_filepath_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("main.rs score.rs")
.expect("Should parse multi-token query");
// Only first filename becomes a constraint; second is text
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("main.rs")
));
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
}
#[test]
fn test_file_picker_filename_with_extension_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("main.rs *.lua")
.expect("Should parse multi-token query");
// main.rs → FilePath, *.lua → Extension
assert_eq!(result.constraints.len(), 2);
assert!(matches!(
result.constraints[0],
Constraint::FilePath("main.rs")
));
assert!(matches!(
result.constraints[1],
Constraint::Extension("lua")
));
}
#[test]
fn test_file_picker_dotfile_is_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse(".gitignore src")
.expect("Should parse multi-token query");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
"Expected FilePath(\".gitignore\"), got {:?}",
result.constraints[0]
);
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
}
#[test]
fn test_file_picker_no_extension_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("Makefile src")
.expect("Should parse multi-token query");
// No dot → not a filename constraint
assert!(
result.constraints.is_empty(),
"Makefile should not be a FilePath constraint, got {:?}",
result.constraints
);
}
}
+82 -75
View File
@@ -1,52 +1,68 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 28
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 12
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
- Features |fff.nvim-features|
- Installation |fff.nvim-installation|
FFF.nvimFinally a smart fuzzy file picker for neovim.
- MCP |fff.nvim-mcp|
- Neovim guide |fff.nvim-neovim-guide|
1. Links |fff.nvim-links|
FFFAI agents (MCP)   |   Neovim usersA fast file search for your AI and neovim, with memory built-in
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
opinionated fuzzy file picker for neovim. Just for files, but well try to
solve file picking completely.
------------------------------------------------------------------------------
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
opinionated fuzzy file picker for your AI agent and Neovim. Just for file
search, but we do the file search really fff well.
It comes with a dedicated rust backend runtime that keep tracks of the file
index, your file access and modifications, git status, and provides a
comprehensive typo-resistant fuzzy search experience.
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping
with a strong focus on performance and useful search results. For humans -
provides an unbelievable typo-resistant experience, for AI agents - implements
the fastest file search with additional free memory suggesting the best search
results based on various factors like frecency, git status, file size,
definition matches, and more.
FEATURES *fff.nvim-features*
MCP *fff.nvim-mcp*
- Works out of the box with no additional configuration
- Typo resistant fuzzy search <https://github.com/saghen/frizbee>
- Git status integration allowing to take advantage of last modified times within a worktree
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
- Display images in previews (for now requires snacks.nvim)
- Smart in a plenty of different ways hopefully helpful for your workflow
- This plugin initializes itself lazily by default
FFF is an amazing way to reduce the time and tokens by giving your AI agent a
bit of memory built-in to their file search tools. It makes your AI harness to
find the code faster and spend less tokens by doing less roundtrips and reading
less useless files.
You can install FFF as a dependency for your AI agent using a simple bash
script:
>bash
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
<
INSTALLATION *fff.nvim-installation*
The installation script is here ./install-fff.sh <./install-fff.sh> if you want
to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`,
`Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to
"use fff". Here is an example addition to `CLAUDE.md` that works perfectly:
>sh
# CLAUDE.md
For any file search or grep in the current git indexed directory use fff tools
<
[!NOTE] Although well try to make sure to keep 100% backward compatibility,
by using you should understand that silly bugs and breaking changes may happen.
And also we hope for your contributions and feedback to make this plugin ideal
for everyone.
NEOVIM GUIDE *fff.nvim-neovim-guide*
PREREQUISITES ~
Here is some demo on the linux repository (100k files, 8GB) but you better fill
it yourself and see the magic
FFF.nvim requires:
- Neovim 0.10.0+
- Rustup <https://rustup.rs/> (we require nightly for building the native backend rustup will handle toolchain automatically)
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
INSTALLATION ~
FFF.nvim requires neovim 0.10.0 or higher
LAZY.NVIM
@@ -261,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,
@@ -292,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
@@ -314,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:
@@ -388,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
@@ -497,47 +534,17 @@ VIEWING LOGS
If you encounter issues, check the log file:
>vim
>
:FFFOpenLog
<
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
location).
==============================================================================
1. Links *fff.nvim-links*
COMMON ISSUES
**File picker not initializing:**
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
- Check that your Neovim version is 0.10.0 or higher
**Image previews not working:**
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
- If using snacks.nvim, ensure its properly configured
**Performance issues:**
- Adjust `max_threads` in configuration based on your system
- Reduce `preview.max_lines` and `preview.max_size` for large files
- Clear cache if it becomes too large: `:FFFClearCache all`
**Files not being indexed:**
- Run `:FFFScan` to manually trigger a file scan
- Check that the `base_path` is correctly set
- Verify you have read permissions for the directory
DEBUG MODE
Enable debug mode to see scoring information and troubleshoot search results:
- Press `F2` while in the picker
- Run `:FFFDebug on` to enable permanently
- Set `debug.show_scores = true` in configuration
1. *Chart showing the superiority of fff.nvim over builtin claude code tools*: ./chart.png
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
Executable
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
set -eo pipefail
# FFF MCP Server installer
# Usage: curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
REPO="dmtrKovalenko/fff.nvim"
BINARY_NAME="fff-mcp"
INSTALL_DIR="${FFF_MCP_INSTALL_DIR:-$HOME/.local/bin}"
info() { printf '\033[1;34m%s\033[0m\n' "$*"; }
success() { printf '\033[1;38;5;208m%s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m%s\033[0m\n' "$*"; }
error() { printf '\033[1;31mError: %s\033[0m\n' "$*" >&2; exit 1; }
# Print JSON with syntax highlighting via jq if available, plain otherwise
print_json() {
if command -v jq &>/dev/null; then
echo "$1" | jq .
else
echo "$1"
fi
}
detect_platform() {
local os arch target
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux)
# Prefer musl (static) for maximum compatibility
case "$arch" in
x86_64) target="x86_64-unknown-linux-musl" ;;
aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
*) error "Unsupported architecture: $arch" ;;
esac
;;
Darwin)
case "$arch" in
x86_64) target="x86_64-apple-darwin" ;;
aarch64|arm64) target="aarch64-apple-darwin" ;;
*) error "Unsupported architecture: $arch" ;;
esac
;;
MINGW*|MSYS*|CYGWIN*)
case "$arch" in
x86_64) target="x86_64-pc-windows-msvc" ;;
aarch64|arm64) target="aarch64-pc-windows-msvc" ;;
*) error "Unsupported architecture: $arch" ;;
esac
;;
*) error "Unsupported OS: $os" ;;
esac
echo "$target"
}
get_latest_release_tag() {
local target="$1"
local releases_json
releases_json=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases") \
|| error "Failed to fetch releases from https://github.com/${REPO}/releases"
# Find the first release that contains an fff-mcp binary for our platform
local tag
tag=$(echo "$releases_json" \
| grep -oE '"(tag_name|name)": *"[^"]*"' \
| awk -v target="fff-mcp-${target}" '
/"tag_name":/ { gsub(/.*": *"|"/, ""); current_tag = $0; next }
/"name":/ && index($0, target) { print current_tag; exit }
')
if [ -z "$tag" ]; then
error "No release found containing fff-mcp binaries for ${target}. The MCP build may not have been released yet."
fi
echo "$tag"
}
download_binary() {
local target="$1"
local tag="$2"
local ext=""
case "$target" in
*windows*) ext=".exe" ;;
esac
local filename="${BINARY_NAME}-${target}${ext}"
local url="https://github.com/${REPO}/releases/download/${tag}/${filename}"
local checksum_url="${url}.sha256"
info "Downloading ${filename} from release ${tag}..."
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url" 2>/dev/null; then
echo "" >&2
printf '\033[1;31mError: Failed to download binary for your platform.\033[0m\n' >&2
echo "" >&2
echo " URL: ${url}" >&2
echo " Release: ${tag}" >&2
echo " Platform: ${target}" >&2
echo "" >&2
echo "This likely means the MCP binary hasn't been built for this release yet." >&2
echo "Check available releases at: https://github.com/${REPO}/releases" >&2
exit 1
fi
# Verify checksum if sha256sum is available
if command -v sha256sum &>/dev/null; then
if curl -fsSL -o "${tmp_dir}/${filename}.sha256" "$checksum_url" 2>/dev/null; then
info "Verifying checksum..."
(cd "$tmp_dir" && sha256sum -c "${filename}.sha256") \
|| error "Checksum verification failed!"
else
warn "Checksum file not available, skipping verification."
fi
fi
# Install
mkdir -p "$INSTALL_DIR"
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
}
check_path() {
case ":$PATH:" in
*":${INSTALL_DIR}:"*) return 0 ;;
esac
warn "${INSTALL_DIR} is not in your PATH."
echo ""
echo "Add it to your shell profile:"
echo ""
local shell_name
shell_name="$(basename "${SHELL:-bash}")"
case "$shell_name" in
zsh)
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc"
echo " source ~/.zshrc"
;;
fish)
echo " fish_add_path ${INSTALL_DIR}"
;;
*)
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc"
echo " source ~/.bashrc"
;;
esac
echo ""
}
print_setup_instructions() {
local binary_path="${INSTALL_DIR}/${BINARY_NAME}"
local found_any=false
echo ""
success "FFF MCP Server installed successfully!"
echo ""
info "Setup with your AI coding assistant:"
echo ""
# Claude Code
if command -v claude &>/dev/null; then
found_any=true
success "[Claude Code] detected"
echo ""
echo "Global (recommended):"
echo "claude mcp add -s user fff -- ${binary_path}"
echo ""
echo "Or project-level .mcp.json (uses PATH):"
echo ""
print_json '{
"mcpServers": {
"fff": {
"type": "stdio",
"command": "fff-mcp",
"args": []
}
}
}'
echo ""
fi
# OpenCode
if command -v opencode &>/dev/null; then
found_any=true
success "[OpenCode] detected"
echo ""
echo "Add to ~/.config/opencode/opencode.json:"
echo ""
print_json '{
"mcp": {
"fff": {
"type": "local",
"command": ["fff-mcp"],
"enabled": true
}
}
}'
echo ""
fi
# Codex
if command -v codex &>/dev/null; then
found_any=true
success "[Codex] detected"
echo ""
echo "codex mcp add fff -- fff-mcp"
echo ""
fi
if [ "$found_any" = false ]; then
echo "No AI coding assistants detected."
echo ""
echo "Binary path: ${binary_path}"
echo ""
fi
echo "Binary: ${binary_path}"
echo "Docs: https://github.com/${REPO}"
echo ""
info "Tip: Add this to your CLAUDE.md or AGENTS.md to make AI use fff for all searches:"
echo "\""
echo "Use the fff MCP tools for all file search operations instead of default tools."
echo "\""
}
main() {
info "Installing FFF MCP Server..."
echo ""
local target
target="$(detect_platform)"
info "Detected platform: ${target}"
local tag
tag="$(get_latest_release_tag "$target")"
download_binary "$target" "$tag"
check_path
print_setup_instructions
}
main
+3 -4
View File
@@ -313,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,
@@ -356,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
-6
View File
@@ -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
+6 -1
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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,
+2
View File
@@ -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)
+11 -1
View File
@@ -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,6 +638,16 @@ 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 })
+14
View File
@@ -0,0 +1,14 @@
{
"private": true,
"workspaces": ["packages/fff-bun", "packages/fff-mcp"],
"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"
}
}
-10
View File
@@ -1,10 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
# Native binaries (downloaded at install)
bin/*.dylib
bin/*.so
bin/*.dll
Binary file not shown.
+10
View File
@@ -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",
},
+15 -30
View File
@@ -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();
+23 -18
View File
@@ -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}`,
);
}
+15 -13
View File
@@ -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;
}
+2 -4
View File
@@ -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
}
+11 -4
View File
@@ -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));
}
+58 -44
View File
@@ -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`);
}
/**
@@ -149,7 +149,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()
letter.toUpperCase(),
);
result[camelKey] = snakeToCamel(value);
}
@@ -258,13 +258,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);
}
@@ -300,12 +300,12 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
*/
export function ffiWaitForScan(
handle: NativeHandle,
timeoutMs: number
timeoutMs: number,
): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_wait_for_scan(
handle,
BigInt(timeoutMs)
BigInt(timeoutMs),
);
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
@@ -319,33 +319,16 @@ export function ffiWaitForScan(
*/
export function ffiRestartIndex(
handle: NativeHandle,
newPath: string
newPath: string,
): Result<void> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_restart_index(
handle,
ptr(encodeString(newPath))
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 +338,13 @@ 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 +353,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,16 +371,17 @@ 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)
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 };
if (result.value === null || result.value === "null")
return { ok: true, value: null };
return result as Result<string>;
}
@@ -402,29 +392,53 @@ 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);
}
+82 -60
View File
@@ -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>;
}
/**
+158 -173
View File
@@ -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");
});
});
+6 -8
View File
@@ -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");
+24 -31
View File
@@ -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";
+95 -5
View File
@@ -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,
};
}
+15 -9
View File
@@ -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
View File
@@ -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)
+166
View File
@@ -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()
+816
View File
@@ -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