Compare commits

...

10 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 9adc2d207b chore: Update docs for - feat: Nodejs FFI package
docs / docs (push) Has been cancelled
2026-03-18 15:23:14 -07:00
Dmitriy Kovalenko a03946740f feat: Nodejs FFI package 2026-03-17 21:03:18 -07:00
Dmitriy Kovalenko 4b83987ea1 feat: Colorized hex dump for binary file previews (#291)
* feat: Colorized hex dump for binary file previews

* chore: Update docs for - feat: Colorized hex dump for binary file previews
2026-03-17 12:27:48 -07:00
Dmitriy Kovalenko c6cb66b597 fix: Windows dir locking (#293)
* fix: Windows dir locking

* chore: Update docs for - fix: Windows dir locking
2026-03-17 10:40:09 -07:00
George 64861f8142 chore(readme) Fix install-fff.sh link to install-mcp.sh (#283)
* install-fff.sh -> install.mcp.sh

* install-fff.sh -> install.mcp.sh
2026-03-15 08:00:18 -07:00
Mohamed Ibraheem eb5f2b3648 refactor: move change_indexing_directory to picker_ui module (#286)
* refactor: move change_indexing_directory to picker_ui module

closes #237

* chore: format

* chore: format
2026-03-15 07:59:38 -07:00
Dmitriy Kovalenko fcdf4a9172 feat(mcp): Make install script understand upgrade use case 2026-03-13 16:45:21 -07:00
Dmitriy Kovalenko 1001eb8b5e feat(mcp): Add file path fallback for grep (#281)
* feat(mcp): Add file path fallback for grep

* chore: Update docs for - feat(mcp): Add file path fallback for grep
2026-03-13 16:29:24 -07:00
Dmitriy Kovalenko f0ce2dd50d chore: fix format 2026-03-13 09:58:27 -07:00
Dmitriy Kovalenko 1c2a1c1204 fix(grep): file path single file constraint parsing (#280) 2026-03-12 17:10:28 -07:00
109 changed files with 7718 additions and 2470 deletions
+25 -5
View File
@@ -9,6 +9,9 @@ on:
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13"
# Force Node 24 for all JS-based actions to avoid the libuv
# process_title assertion crash on Windows (known Node 20 bug).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lua-tests:
@@ -20,12 +23,12 @@ jobs:
fail-fast: false
matrix:
include:
# - os: ubuntu-latest TODO uncomment once bun stop crashing
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install Zig
@@ -34,11 +37,11 @@ jobs:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
cache-key: "v1-lua-e2e"
cache-on-failure: false
cache-key: "v2-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}
@@ -89,4 +92,21 @@ jobs:
- name: Run bun tests
shell: bash
if: ${{ matrix.os == 'macos-latest' }}
run: make test-bun
- name: Install Node.js
if: ${{ matrix.os != 'ubuntu-latest' }}
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install node deps
if: ${{ matrix.os != 'ubuntu-latest' }}
shell: bash
run: npm install
- name: Run node tests
if: ${{ matrix.os != 'ubuntu-latest' }}
shell: bash
run: make test-node
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
name: lua-language-server type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Neovim
run: |
@@ -38,7 +38,7 @@ jobs:
name: luacheck lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install luacheck
run: |
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
# fetch last 2 commits required for auto force push back
fetch-depth: 2
+78 -112
View File
@@ -2,9 +2,14 @@ name: Prebuild
on:
push:
branches: [main, feat/mcp-ai]
branches: [main]
tags:
- "v*"
pull_request:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-nvim:
name: Build Neovim ${{ matrix.target }}
@@ -62,7 +67,7 @@ jobs:
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -134,26 +139,26 @@ jobs:
target: x86_64-unknown-linux-gnu
zigbuild_target: x86_64-unknown-linux-gnu.2.17
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
npm_package: fff-bun-linux-x64-gnu
npm_package: fff-bin-linux-x64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
zigbuild_target: aarch64-unknown-linux-gnu.2.17
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_c.so
npm_package: fff-bun-linux-arm64-gnu
npm_package: fff-bin-linux-arm64-gnu
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_c.so
npm_package: fff-bun-linux-x64-musl
npm_package: fff-bin-linux-x64-musl
lib_filename: libfff_c.so
ext: so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_c.so
npm_package: fff-bun-linux-arm64-musl
npm_package: fff-bin-linux-arm64-musl
lib_filename: libfff_c.so
ext: so
@@ -168,13 +173,13 @@ jobs:
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
npm_package: fff-bun-darwin-x64
npm_package: fff-bin-darwin-x64
lib_filename: libfff_c.dylib
ext: dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/libfff_c.dylib
npm_package: fff-bun-darwin-arm64
npm_package: fff-bin-darwin-arm64
lib_filename: libfff_c.dylib
ext: dylib
@@ -182,18 +187,18 @@ jobs:
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff_c.dll
npm_package: fff-bun-win32-x64
npm_package: fff-bin-win32-x64
lib_filename: fff_c.dll
ext: dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff_c.dll
npm_package: fff-bun-win32-arm64
npm_package: fff-bin-win32-arm64
lib_filename: fff_c.dll
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -306,7 +311,7 @@ jobs:
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -360,7 +365,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -425,25 +430,22 @@ jobs:
fi
done
- name: Prepare tag
id: vars
shell: bash
run: |
sha="$(git rev-parse --short HEAD)"
echo "tag=$sha" >> $GITHUB_OUTPUT
- name: Determine version
id: version
run: ./scripts/determine-version.sh
- name: Upload Release Assets
uses: softprops/action-gh-release@v2
with:
name: "${{ steps.vars.outputs.tag }}"
tag_name: "${{ steps.vars.outputs.tag }}"
name: "${{ steps.version.outputs.version }}"
tag_name: "${{ steps.version.outputs.is_release == 'true' && format('v{0}', steps.version.outputs.version) || steps.version.outputs.version }}"
token: ${{ github.token }}
files: ./binaries/*
draft: false
prerelease: true
generate_release_notes: false
prerelease: ${{ steps.version.outputs.is_release != 'true' }}
generate_release_notes: ${{ steps.version.outputs.is_release == 'true' }}
body: |
Nightly release from commit: ${{ github.sha }}
${{ steps.version.outputs.is_release == 'true' && format('Release {0}', steps.version.outputs.version) || format('Nightly release from commit: {0}', github.sha) }}
## Neovim Plugin
- `{target}.so` / `.dylib` / `.dll` - Lua module for Neovim
@@ -459,39 +461,52 @@ jobs:
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
```
crates-publish:
name: Publish Rust crates
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/node' || startsWith(github.ref, 'refs/tags/v')))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'node'))
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-edit
run: cargo install cargo-edit
- name: Determine version
id: version
run: ./scripts/determine-version.sh
- name: Publish crates
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: make publish-crates V="${{ steps.version.outputs.version }}"
npm-publish:
name: Publish npm packages
needs: [build-c]
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/interchangable-ffi'))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/interchangable-ffi'))
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/node' || startsWith(github.ref, 'refs/tags/v')))
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'node'))
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "25"
registry-url: "https://registry.npmjs.org"
- name: Determine version
id: version
run: |
# Read the base version from fff-core Cargo.toml (single source of truth)
base_version=$(grep '^version' crates/fff-core/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
short_sha=$(git rev-parse --short HEAD)
# Always publish as nightly prerelease: X.Y.Z-nightly.<short-sha>
echo "version=${base_version}-nightly.${short_sha}" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tag=nightly" >> $GITHUB_OUTPUT
else
echo "tag=dev" >> $GITHUB_OUTPUT
fi
run: ./scripts/determine-version.sh
- name: Download npm package artifacts
uses: actions/download-artifact@v4
@@ -504,94 +519,45 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
TAG="${{ steps.version.outputs.npm_tag }}"
for pkg_dir in ./npm-packages/npm-*/; do
if [ -d "$pkg_dir" ]; then
pkg_name=$(node -p "require('${pkg_dir}package.json').name")
echo "Publishing ${pkg_name}@${VERSION} with tag ${TAG}..."
# Update version in package.json
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('${pkg_dir}package.json', 'utf8'));
pkg.version = '${VERSION}';
fs.writeFileSync('${pkg_dir}package.json', JSON.stringify(pkg, null, 2) + '\n');
"
make set-npm-version PKG="$pkg_dir" VERSION="$VERSION"
cd "$pkg_dir"
npm publish --tag "$TAG" --access public || echo "Failed to publish ${pkg_name} (may already exist)"
cd -
fi
done
- name: Publish main package
- name: Publish bun package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing @ff-labs/fff-bun@${VERSION} with tag ${TAG}..."
# Update version and optionalDependencies versions in the main package
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./packages/fff-bun/package.json', 'utf8'));
pkg.version = '${VERSION}';
if (pkg.optionalDependencies) {
for (const dep of Object.keys(pkg.optionalDependencies)) {
pkg.optionalDependencies[dep] = '${VERSION}';
}
}
fs.writeFileSync('./packages/fff-bun/package.json', JSON.stringify(pkg, null, 2) + '\n');
"
make set-npm-version PKG=packages/fff-bun VERSION="$VERSION"
cd packages/fff-bun
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-bun (may already exist)"
comment-on-pr:
name: Comment on PR
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
# comments doesn't work on forks
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim' && github.event.pull_request.user.login == 'dmtrKovalenko'
permissions:
pull-requests: write
steps:
- name: Get short SHA
id: vars
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- name: Publish Node.js package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
- name: Find existing comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- fff-nvim-build-comment -->"
echo "Publishing @ff-labs/fff-node@${VERSION} with tag ${TAG}..."
make set-npm-version PKG=packages/fff-node VERSION="$VERSION"
- name: Create or update PR comment
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
edit-mode: replace
body: |
<!-- fff-nvim-build-comment -->
## Build Artifacts for your PR
### Neovim Plugin
Test with lazy.nvim:
```lua
{
"dmtrKovalenko/fff.nvim",
tag = "${{ steps.vars.outputs.short_sha }}",
}
```
### Bun/TypeScript Package
The `fff` npm package will download binaries from this release automatically.
---
*Built from ${{ github.sha }}*
cd packages/fff-node
npm install
npx tsc
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-node (may already exist)"
+6 -10
View File
@@ -20,7 +20,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
@@ -29,7 +29,7 @@ jobs:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
@@ -37,15 +37,13 @@ jobs:
components: rustfmt, clippy
- name: Run tests
run: |
cargo test --verbose -p fff-core -p fff-query-parser -p fff-c --features zlob
cargo test --verbose -p grep-searcher
run: cargo test --features zlob --workspace --exclude fff-nvim
fmt:
name: cargo fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
@@ -58,7 +56,7 @@ jobs:
name: cargo clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
@@ -73,6 +71,4 @@ jobs:
components: clippy
- name: Run clippy
run: |
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
cargo clippy -p grep-searcher -- -D warnings
run: cargo clippy -- -D warnings
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
name: Check lua files using Stylua
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
Generated
+56 -56
View File
@@ -81,7 +81,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -92,7 +92,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -591,10 +591,10 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fff-c"
version = "0.1.0"
version = "0.2.4"
dependencies = [
"fff-core",
"fff-query-parser",
"fff-search",
"git2",
"mimalloc",
"serde",
@@ -603,56 +603,21 @@ dependencies = [
]
[[package]]
name = "fff-core"
version = "0.1.0"
name = "fff-grep"
version = "0.2.4"
dependencies = [
"ahash",
"aho-corasick",
"bindet",
"blake3",
"chrono",
"criterion",
"dirs",
"dunce",
"fff-query-parser",
"git2",
"glidesort",
"globset",
"bstr",
"grep-matcher",
"grep-searcher",
"heed",
"ignore",
"memchr",
"memmap2",
"neo_frizbee",
"notify",
"notify-debouncer-full 0.7.0",
"once_cell",
"parking_lot",
"pathdiff",
"rand",
"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"
version = "0.2.4"
dependencies = [
"clap",
"fff-core",
"fff-query-parser",
"fff-search",
"git2",
"mimalloc",
"rmcp",
@@ -665,7 +630,7 @@ dependencies = [
[[package]]
name = "fff-nvim"
version = "0.1.0"
version = "0.2.4"
dependencies = [
"ahash",
"blake3",
@@ -673,8 +638,8 @@ dependencies = [
"criterion",
"ctrlc",
"dirs",
"fff-core",
"fff-query-parser",
"fff-search",
"git2",
"glidesort",
"heed",
@@ -698,13 +663,57 @@ dependencies = [
[[package]]
name = "fff-query-parser"
version = "0.1.0"
version = "0.2.4"
dependencies = [
"criterion",
"smallvec",
"zlob",
]
[[package]]
name = "fff-search"
version = "0.2.4"
dependencies = [
"ahash",
"aho-corasick",
"bindet",
"blake3",
"chrono",
"criterion",
"dirs",
"dunce",
"fff-grep",
"fff-query-parser",
"git2",
"glidesort",
"globset",
"grep-matcher",
"heed",
"ignore",
"memchr",
"memmap2",
"neo_frizbee",
"notify",
"notify-debouncer-full 0.7.0",
"once_cell",
"parking_lot",
"pathdiff",
"rand",
"rayon",
"regex",
"serde",
"serde_json",
"smallvec",
"smartstring",
"tempfile",
"thiserror 2.0.12",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zlob",
]
[[package]]
name = "file-id"
version = "0.2.3"
@@ -890,15 +899,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "grep-searcher"
version = "0.1.16"
dependencies = [
"bstr",
"grep-matcher",
"memchr",
]
[[package]]
name = "half"
version = "2.7.1"
+9 -3
View File
@@ -5,11 +5,14 @@ members = [
"crates/fff-mcp",
"crates/fff-nvim",
"crates/fff-query-parser",
"crates/fff-searcher",
"crates/fff-grep",
]
resolver = "2"
[workspace.dependencies]
fff-grep = { version = "0.2.3", path = "crates/fff-grep" }
fff-query-parser = { version = "0.2.3", path = "crates/fff-query-parser", default-features = false }
# Shared dependencies
ahash = "0.8"
bindet = "0.3"
@@ -25,7 +28,6 @@ git2 = { version = "0.20.2", default-features = false, features = [
glidesort = "0.1"
globset = "0.4"
grep-matcher = "0.1.8"
grep-searcher = { path = "crates/fff-searcher" }
heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
@@ -41,7 +43,11 @@ parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
regex = "1.11"
smallvec = { version = "1.13", features = ["const_generics", "union"] }
smallvec = { version = "1.13", features = [
"const_generics",
"union",
"may_dangle",
] }
thiserror = "2.0.10"
tracing = "0.1"
+44 -7
View File
@@ -1,10 +1,13 @@
PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
.PHONY: build test test-rust test-lua test-bun test-node prepare-bun prepare-node set-npm-version header
build:
cargo build --release --features zlob
header:
cbindgen --config crates/fff-c/cbindgen.toml --crate fff-c --output crates/fff-c/include/fff.h
test-setup:
@if [ ! -d "$(PLENARY_DIR)" ]; then \
echo "Cloning plenary.nvim..."; \
@@ -12,7 +15,7 @@ test-setup:
fi
test-rust:
cargo test --workspace --features zlob
cargo test --workspace --features zlob --exclude fff-nvim
test-lua: test-setup build
nvim --headless -u tests/minimal_init.lua \
@@ -24,15 +27,39 @@ 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
prepare-node: build
mkdir -p packages/fff-node/bin
cp target/release/libfff_c.dylib packages/fff-node/bin/ 2>/dev/null; \
cp target/release/libfff_c.so packages/fff-node/bin/ 2>/dev/null; \
cp target/release/fff_c.dll packages/fff-node/bin/ 2>/dev/null; \
true
test-bun: prepare-bun
cd packages/fff-bun && bun test src/
test: test-rust test-lua test-bun
test-node: prepare-node
cd packages/fff-node && npm run build && node test/e2e.mjs
test: test-rust test-lua test-bun test-node
# Update version in a package.json, including optionalDependencies.
# Usage: make set-npm-version PKG=packages/fff-bun VERSION=1.0.0-nightly.abc1234
set-npm-version:
@test -n "$(PKG)" || (echo "PKG is required" && exit 1)
@test -n "$(VERSION)" || (echo "VERSION is required" && exit 1)
node -e " \
const fs = require('fs'); \
const pkg = JSON.parse(fs.readFileSync('$(PKG)/package.json', 'utf8')); \
pkg.version = '$(VERSION)'; \
if (pkg.optionalDependencies) { \
for (const dep of Object.keys(pkg.optionalDependencies)) { \
pkg.optionalDependencies[dep] = '$(VERSION)'; \
} \
} \
fs.writeFileSync('$(PKG)/package.json', JSON.stringify(pkg, null, 2) + '\n'); \
"
@echo "Set $(PKG) to $(VERSION)"
format-rust:
cargo fmt --all
@@ -53,3 +80,13 @@ lint-ts:
lint: lint-rust lint-lua lint-ts
check: format lint
CRATES_TO_PUBLISH= fff-grep fff-query-parser fff-search
publish-crates:
@test -n "$(V)" || (echo "V is required. Usage: make publish-crates V=0.2.0" && exit 1)
cargo install cargo-edit
cargo set-version $(V) || exit 1;
@for crate in $(CRATES_TO_PUBLISH); do \
cargo publish -p $$crate --allow-dirty $$(if [ -n "$$CI" ]; then echo "--no-verify"; fi) || exit 1; \
done
+1 -1
View File
@@ -36,7 +36,7 @@ You can install FFF as a dependency for your AI agent using a simple bash script
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
```
> The installation script is here [./install-fff.sh](./install-fff.sh) if you want to review it before running.
> The installation script is here [./install-mcp.sh](./install-mcp.sh) if you want to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
Here is an example addition to `CLAUDE.md` that works perfectly:
+40 -189
View File
@@ -33,26 +33,31 @@
"bun": ">=1.0.0",
},
},
"packages/fff-mcp": {
"name": "@ff-labs/fff-mcp",
"version": "0.1.0",
"packages/fff-node": {
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"bin": {
"fff-mcp": "./src/index.ts",
"fff-node": "./dist/scripts/cli.js",
},
"dependencies": {
"@ff-labs/fff-bun": "workspace:*",
"@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.24.0",
"ffi-rs": "^1.0.0",
},
"devDependencies": {
"@types/bun": "^1.3.8",
"@types/node": "^22.0.0",
"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",
},
},
},
"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=="],
@@ -74,11 +79,7 @@
"@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=="],
"@ff-labs/fff-node": ["@ff-labs/fff-node@workspace:packages/fff-node"],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w=="],
@@ -106,192 +107,42 @@
"@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=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
"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=="],
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
"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=="],
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ=="],
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q=="],
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg=="],
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A=="],
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA=="],
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA=="],
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.1", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg=="],
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw=="],
"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=="],
"ffi-rs": ["ffi-rs@1.3.1", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-x64": "1.3.1", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.1", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.1", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-x64-musl": "1.3.1", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.1", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.1", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.1" } }, "sha512-ZyNXL9fnclnZV+waQmWB9JrfbIEyxQa1OWtMrHOrAgcC04PgP5hBMG5TdhVN8N4uT/eul8zCFMVnJUukAFFlXA=="],
"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=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"bun-types/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"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=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}
+5 -5
View File
@@ -1,8 +1,8 @@
[package]
name = "fff-c"
version = "0.1.0"
version = "0.2.4"
edition = "2024"
description = "C FFI bindings for fff-core - use from any language with C FFI support"
description = "Raw C api of FFF file finder"
license = "MIT"
[lib]
@@ -10,14 +10,14 @@ crate-type = ["cdylib"]
[features]
default = []
zlob = ["fff-core/zlob"]
zlob = ["fff/zlob"]
[dependencies]
mimalloc.workspace = true
tracing.workspace = true
git2.workspace = true
fff-core = { path = "../fff-core" }
fff-query-parser = { path = "../fff-query-parser" }
fff = { package = "fff-search", path = "../fff-core" , version = "0.2.2" }
fff-query-parser = { path = "../fff-query-parser" , version = "0.2.4" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+28
View File
@@ -0,0 +1,28 @@
language = "C"
header = "/* Generated by cbindgen — do not edit manually. */"
include_guard = "FFF_C_H"
include_version = true
no_includes = true
sys_includes = ["stdint.h", "stdbool.h", "stddef.h"]
[export]
include = [
"FffResult",
"FffSearchResult", "FffFileItem", "FffScore", "FffLocation",
"FffGrepResult", "FffGrepMatch", "FffMatchRange",
"FffScanProgress",
]
[export.rename]
"FffResult" = "FffResult"
"FffSearchResult" = "FffSearchResult"
"FffFileItem" = "FffFileItem"
"FffScore" = "FffScore"
"FffLocation" = "FffLocation"
"FffGrepResult" = "FffGrepResult"
"FffGrepMatch" = "FffGrepMatch"
"FffMatchRange" = "FffMatchRange"
"FffScanProgress" = "FffScanProgress"
[fn]
sort_by = "None"
+539
View File
@@ -0,0 +1,539 @@
/* Generated by cbindgen — do not edit manually. */
#ifndef FFF_C_H
#define FFF_C_H
/* Generated with cbindgen:0.29.2 */
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
/**
* Result envelope returned by all `fff_*` functions.
*
* Heap-allocated — the caller must free it with `fff_free_result`.
*
* Depending on the function, the payload is delivered through different fields:
*
* | Function | Payload field | Type |
* |----------------------------|---------------|-------------------------------|
* | `fff_create_instance` | `handle` | opaque instance pointer |
* | `fff_search` | `handle` | `*mut FffSearchResult` |
* | `fff_live_grep` | `handle` | `*mut FffGrepResult` |
* | `fff_multi_grep` | `handle` | `*mut FffGrepResult` |
* | `fff_get_scan_progress` | `handle` | `*mut FffScanProgress` |
* | `fff_health_check` | `handle` | `*mut c_char` (JSON string) |
* | `fff_get_historical_query` | `handle` | `*mut c_char` (string or null)|
* | `fff_wait_for_scan` | `int_value` | 1 = completed, 0 = timed out |
* | `fff_track_query` | `int_value` | 1 = success, 0 = failure |
* | `fff_refresh_git_status` | `int_value` | number of files updated |
* | `fff_scan_files` | (none) | success flag only |
* | `fff_restart_index` | (none) | success flag only |
*
* On failure, `success` is false and `error` contains the message.
*
* **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
* The caller must free the handle with the appropriate function
* (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
* `fff_free_string`, etc.).
*/
typedef struct FffResult {
/**
* Whether the operation succeeded.
*/
bool success;
/**
* Error message on failure. Null on success.
*/
char *error;
/**
* Opaque pointer payload (instance handle, typed result struct, or string). May be null.
*/
void *handle;
/**
* Integer payload for simple return values (bool as 0/1, counts, etc.).
*/
int64_t int_value;
} FffResult;
/**
* A file item returned by `fff_search`.
*
* All string fields are heap-allocated and owned by the parent `FffSearchResult`.
* Free the entire result with `fff_free_search_result`.
*/
typedef struct FffFileItem {
char *path;
char *relative_path;
char *file_name;
char *git_status;
uint64_t size;
uint64_t modified;
int64_t access_frecency_score;
int64_t modification_frecency_score;
int64_t total_frecency_score;
bool is_binary;
} FffFileItem;
/**
* Score breakdown for a search result.
*/
typedef struct FffScore {
int32_t total;
int32_t base_score;
int32_t filename_bonus;
int32_t special_filename_bonus;
int32_t frecency_boost;
int32_t distance_penalty;
int32_t current_file_penalty;
int32_t combo_match_boost;
bool exact_match;
char *match_type;
} FffScore;
/**
* Location parsed from a query string (e.g. `"file.ts:42:10"`).
*
* `tag` encodes the variant:
* 0 = no location,
* 1 = line only (`line` is set),
* 2 = position (`line` + `col`),
* 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
*/
typedef struct FffLocation {
uint8_t tag;
int32_t line;
int32_t col;
int32_t end_line;
int32_t end_col;
} FffLocation;
/**
* Search result returned by `fff_search`.
*
* The caller must free this with `fff_free_search_result`.
*/
typedef struct FffSearchResult {
/**
* Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
*/
struct FffFileItem *items;
/**
* Pointer to a heap-allocated array of `FffScore` (length = `count`).
*/
struct FffScore *scores;
/**
* Number of items/scores in the arrays.
*/
uint32_t count;
/**
* Total number of files that matched the query.
*/
uint32_t total_matched;
/**
* Total number of indexed files.
*/
uint32_t total_files;
/**
* Location parsed from the query string.
*/
struct FffLocation location;
} FffSearchResult;
/**
* A byte range within a matched line, used for highlighting.
*/
typedef struct FffMatchRange {
uint32_t start;
uint32_t end;
} FffMatchRange;
/**
* A single grep match with file and line information.
*
* All string fields and arrays are heap-allocated. Free the parent
* `FffGrepResult` with `fff_free_grep_result` to release everything.
*/
typedef struct FffGrepMatch {
char *path;
char *relative_path;
char *file_name;
char *git_status;
char *line_content;
struct FffMatchRange *match_ranges;
char **context_before;
char **context_after;
uint64_t size;
uint64_t modified;
int64_t total_frecency_score;
int64_t access_frecency_score;
int64_t modification_frecency_score;
uint64_t line_number;
uint64_t byte_offset;
uint32_t col;
uint32_t match_ranges_count;
uint32_t context_before_count;
uint32_t context_after_count;
uint16_t fuzzy_score;
bool has_fuzzy_score;
bool is_binary;
bool is_definition;
} FffGrepMatch;
/**
* Grep result returned by `fff_live_grep` and `fff_multi_grep`.
*
* The caller must free this with `fff_free_grep_result`.
*/
typedef struct FffGrepResult {
/**
* Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
*/
struct FffGrepMatch *items;
/**
* Number of matches in the `items` array.
*/
uint32_t count;
/**
* Total number of matches (always equal to `count`).
*/
uint32_t total_matched;
/**
* Number of files actually opened and searched in this call.
*/
uint32_t total_files_searched;
/**
* Total number of indexed files (before any filtering).
*/
uint32_t total_files;
/**
* Number of files eligible for search after filtering.
*/
uint32_t filtered_file_count;
/**
* File offset for the next page. 0 if all files have been searched.
*/
uint32_t next_file_offset;
/**
* Regex compilation error when falling back to literal matching. Null if none.
*/
char *regex_fallback_error;
} FffGrepResult;
/**
* Scan progress returned by `fff_get_scan_progress`.
*
* The caller must free this with `fff_free_scan_progress`.
*/
typedef struct FffScanProgress {
uint64_t scanned_files_count;
bool is_scanning;
} FffScanProgress;
/**
* Create a new file finder instance.
*
* Returns an opaque pointer that must be passed to all other `fff_*` calls
* and eventually freed with `fff_destroy`.
*
* # Parameters
*
* * `base_path` directory to index (required)
* * `frecency_db_path` path to frecency LMDB database (NULL/empty to skip)
* * `history_db_path` path to query history LMDB database (NULL/empty to skip)
* * `use_unsafe_no_lock` use MDB_NOLOCK for LMDB (useful in single-process setups)
* * `warmup_mmap_cache` pre-populate mmap caches after the initial scan
* * `ai_mode` enable AI-agent optimizations (auto-track frecency on modifications)
*
* ## Safety
* String parameters must be valid null-terminated UTF-8 or NULL.
*/
struct FffResult *fff_create_instance(const char *base_path,
const char *frecency_db_path,
const char *history_db_path,
bool use_unsafe_no_lock,
bool warmup_mmap_cache,
bool ai_mode);
/**
* Destroy a file finder instance and free all its resources.
*
* ## Safety
* `fff_handle` must be a valid pointer returned by `fff_create_instance`, or null (no-op).
*/
void fff_destroy(void *fff_handle);
/**
* Perform fuzzy search on indexed files.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query string
* * `current_file` path of the currently open file for deprioritization (NULL/empty to skip)
* * `max_threads` maximum worker threads (0 = auto-detect)
* * `page_index` pagination offset (0 = first page)
* * `page_size` results per page (0 = default 100)
* * `combo_boost_multiplier` score multiplier for combo matches (0 = default 100)
* * `min_combo_count` minimum combo count before boost applies (0 = default 3)
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` and `current_file` must be valid null-terminated UTF-8 strings or NULL.
*/
struct FffResult *fff_search(void *fff_handle,
const char *query,
const char *current_file,
uint32_t max_threads,
uint32_t page_index,
uint32_t page_size,
int32_t combo_boost_multiplier,
uint32_t min_combo_count);
/**
* Perform content search (grep) across indexed files.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `query` search query (supports constraint syntax like `*.rs pattern`)
* * `mode` 0 = plain text (SIMD), 1 = regex, 2 = fuzzy
* * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
* * `max_matches_per_file` max matches per file (0 = unlimited)
* * `smart_case` case-insensitive when query is all lowercase
* * `file_offset` file-based pagination offset (0 = start)
* * `page_limit` max matches to return (0 = default 50)
* * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
* * `before_context` context lines before each match
* * `after_context` context lines after each match
* * `classify_definitions` tag matches that are code definitions
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` must be a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_live_grep(void *fff_handle,
const char *query,
uint8_t mode,
uint64_t max_file_size,
uint32_t max_matches_per_file,
bool smart_case,
uint32_t file_offset,
uint32_t page_limit,
uint64_t time_budget_ms,
uint32_t before_context,
uint32_t after_context,
bool classify_definitions);
/**
* 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.
*
* # Parameters
*
* * `fff_handle` instance from `fff_create_instance`
* * `patterns_joined` patterns separated by `\n` (e.g. `"foo\nbar\nbaz"`)
* * `constraints` file filter like `"*.rs"` or `"/src/"` (NULL/empty to skip)
* * `max_file_size` skip files larger than this in bytes (0 = default 10 MB)
* * `max_matches_per_file` max matches per file (0 = unlimited)
* * `smart_case` case-insensitive when all patterns are lowercase
* * `file_offset` file-based pagination offset (0 = start)
* * `page_limit` max matches to return (0 = default 50)
* * `time_budget_ms` wall-clock budget in ms (0 = unlimited)
* * `before_context` context lines before each match
* * `after_context` context lines after each match
* * `classify_definitions` tag matches that are code definitions
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `patterns_joined` and `constraints` must be valid null-terminated UTF-8 or NULL.
*/
struct FffResult *fff_multi_grep(void *fff_handle,
const char *patterns_joined,
const char *constraints,
uint64_t max_file_size,
uint32_t max_matches_per_file,
bool smart_case,
uint32_t file_offset,
uint32_t page_limit,
uint64_t time_budget_ms,
uint32_t before_context,
uint32_t after_context,
bool classify_definitions);
/**
* Trigger a rescan of the file index.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_scan_files(void *fff_handle);
/**
* Check if a scan is currently in progress.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
bool fff_is_scanning(void *fff_handle);
/**
* Get scan progress information.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_get_scan_progress(void *fff_handle);
/**
* Wait for initial scan to complete.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_wait_for_scan(void *fff_handle, uint64_t timeout_ms);
/**
* Restart indexing in a new directory.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `new_path` must be a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_restart_index(void *fff_handle, const char *new_path);
/**
* Refresh git status cache.
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_refresh_git_status(void *fff_handle);
/**
* Track query completion for smart suggestions.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`.
* * `query` and `file_path` must be valid null-terminated UTF-8 strings.
*/
struct FffResult *fff_track_query(void *fff_handle, const char *query, const char *file_path);
/**
* Get historical query by offset (0 = most recent).
*
* ## Safety
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
*/
struct FffResult *fff_get_historical_query(void *fff_handle, uint64_t offset);
/**
* Get health check information.
*
* ## Safety
* * `fff_handle` must be a valid instance pointer from `fff_create_instance`, or null for
* a limited health check (version + git only).
* * `test_path` can be null or a valid null-terminated UTF-8 string.
*/
struct FffResult *fff_health_check(void *fff_handle, const char *test_path);
/**
* Free a search result returned by `fff_search`.
*
* This frees the `FffSearchResult` struct, its `items` and `scores` arrays,
* and all heap-allocated strings within each item and score.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_search`, or null (no-op).
*/
void fff_free_search_result(struct FffSearchResult *result);
/**
* Get a pointer to the `index`-th `FffFileItem` in a search result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the search result is freed.
*
* ## Safety
* `result` must be a valid `FffSearchResult` pointer from `fff_search`.
*/
const struct FffFileItem *fff_search_result_get_item(const struct FffSearchResult *result,
uint32_t index);
/**
* Get a pointer to the `index`-th `FffScore` in a search result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the search result is freed.
*
* ## Safety
* `result` must be a valid `FffSearchResult` pointer from `fff_search`.
*/
const struct FffScore *fff_search_result_get_score(const struct FffSearchResult *result,
uint32_t index);
/**
* Free a grep result returned by `fff_live_grep` or `fff_multi_grep`.
*
* This frees the `FffGrepResult` struct, its `items` array, and all
* heap-allocated strings, match ranges, and context arrays within each match.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_live_grep` or `fff_multi_grep`, or null (no-op).
*/
void fff_free_grep_result(struct FffGrepResult *result);
/**
* Get a pointer to the `index`-th `FffGrepMatch` in a grep result.
*
* Returns null if `result` is null or `index >= result->count`.
* The returned pointer is valid until the grep result is freed.
*
* ## Safety
* `result` must be a valid `FffGrepResult` pointer from `fff_live_grep` or `fff_multi_grep`.
*/
const struct FffGrepMatch *fff_grep_result_get_match(const struct FffGrepResult *result,
uint32_t index);
/**
* Free a scan progress result returned by `fff_get_scan_progress`.
*
* ## Safety
* `result` must be a valid pointer previously returned via `FffResult.handle`
* from `fff_get_scan_progress`, or null (no-op).
*/
void fff_free_scan_progress(struct FffScanProgress *result);
/**
* Offset a pointer by `byte_offset` bytes.
*
* General-purpose utility for FFI consumers that need pointer arithmetic
* (e.g. iterating over arrays). Returns null if `base` is null.
*
* ## Safety
* The resulting pointer must be within the bounds of the original allocation.
*/
const void *fff_ptr_offset(const void *base, uintptr_t byte_offset);
/**
* Free a result returned by any `fff_*` function.
*
* ## Safety
* `result_ptr` must be a valid pointer returned by a `fff_*` function.
*/
void fff_free_result(struct FffResult *result_ptr);
/**
* Free a string returned by `fff_*` functions.
*
* ## Safety
* `s` must be a valid C string allocated by this library.
*/
void fff_free_string(char *s);
#endif /* FFF_C_H */
+430 -305
View File
@@ -1,152 +1,122 @@
//! FFI-compatible type definitions
//!
//! These types use #[repr(C)] for C ABI compatibility and implement
//! serde traits for JSON serialization.
//! All result types use `#[repr(C)]` structs for direct memory access from any
//! language with C FFI support. No JSON serialization is used for search or grep
//! results — callers read struct fields directly.
use std::ffi::{CString, c_char, c_void};
use std::ptr;
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
use serde::{Deserialize, Serialize};
use fff::git::format_git_status;
use fff::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
/// Result type returned by all FFI functions
/// Returned as a heap-allocated pointer that must be freed with fff_free_result
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Allocate a heap CString from a `&str`, returning a raw pointer.
fn cstring_new(s: &str) -> *mut c_char {
CString::new(s).unwrap_or_default().into_raw()
}
/// Convert a `Vec<T>` into a raw pointer + count, leaking the memory.
fn vec_to_raw<T>(v: Vec<T>) -> (*mut T, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let count = v.len() as u32;
let mut boxed = v.into_boxed_slice();
let p = boxed.as_mut_ptr();
std::mem::forget(boxed);
(p, count)
}
/// Convert a `&[String]` into a heap-allocated array of C strings.
fn strings_to_raw(v: &[String]) -> (*mut *mut c_char, u32) {
if v.is_empty() {
return (ptr::null_mut(), 0);
}
let ptrs: Vec<*mut c_char> = v.iter().map(|s| cstring_new(s)).collect();
vec_to_raw(ptrs)
}
/// Free a heap-allocated array of C strings.
///
/// ## Safety
/// `arr` must have been produced by `strings_to_raw`.
unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) {
if arr.is_null() {
return;
}
unsafe {
let ptrs = Vec::from_raw_parts(arr, count as usize, count as usize);
for p in ptrs {
if !p.is_null() {
drop(CString::from_raw(p));
}
}
}
}
/// A file item returned by `fff_search`.
///
/// All string fields are heap-allocated and owned by the parent `FffSearchResult`.
/// Free the entire result with `fff_free_search_result`.
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded
pub success: bool,
/// JSON data on success (null-terminated string, caller must free)
pub data: *mut c_char,
/// Error message on failure (null-terminated string, caller must free)
pub error: *mut c_char,
/// Opaque handle pointer (used by fff_create to return the instance)
pub handle: *mut c_void,
}
impl FffResult {
/// Create a successful result with no data, returned as heap pointer
pub fn ok_empty() -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
/// Create a successful result with data, returned as heap pointer
pub fn ok_data(data: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: CString::new(data).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
handle: ptr::null_mut(),
}))
}
/// Create a successful result carrying an opaque instance handle.
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
data: ptr::null_mut(),
error: ptr::null_mut(),
handle,
}))
}
/// Create an error result, returned as heap pointer
pub fn err(error: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: false,
data: ptr::null_mut(),
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
}))
}
}
/// Initialization options (JSON-deserializable)
#[derive(Debug, Deserialize)]
pub struct InitOptions {
/// Base directory to index (required)
pub base_path: String,
/// Path to frecency database (optional, omit to skip frecency initialization)
pub frecency_db_path: Option<String>,
/// Path to query history database (optional, omit to skip query tracker initialization)
pub history_db_path: Option<String>,
/// Use unsafe no-lock mode for databases (optional, defaults to false)
#[serde(default)]
pub use_unsafe_no_lock: bool,
/// Pre-populate mmap caches for all files after initial scan so the first
/// grep search is as fast as subsequent ones (optional, defaults to false)
#[serde(default)]
pub warmup_mmap_cache: bool,
/// 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)
#[derive(Debug, Default, Deserialize)]
pub struct SearchOptions {
/// Maximum threads for parallel search (0 = auto)
pub max_threads: Option<usize>,
/// Current file path (for deprioritization)
pub current_file: Option<String>,
/// Combo boost score multiplier
pub combo_boost_multiplier: Option<i32>,
/// Minimum combo count for boost
pub min_combo_count: Option<u32>,
/// Page index for pagination
pub page_index: Option<usize>,
/// Page size for pagination
pub page_size: Option<usize>,
}
/// Scan progress (JSON-serializable)
#[derive(Debug, Serialize)]
pub struct ScanProgress {
pub scanned_files_count: usize,
pub is_scanning: bool,
}
/// File item for JSON serialization
#[derive(Debug, Serialize)]
pub struct FileItemJson {
pub path: String,
pub relative_path: String,
pub file_name: String,
pub struct FffFileItem {
pub path: *mut c_char,
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
pub git_status: String,
pub is_binary: bool,
}
impl FileItemJson {
pub fn from_file_item(item: &FileItem) -> Self {
FileItemJson {
path: item.path.to_string_lossy().to_string(),
relative_path: item.relative_path.clone(),
file_name: item.file_name.clone(),
impl From<&FileItem> for FffFileItem {
fn from(item: &FileItem) -> Self {
FffFileItem {
path: cstring_new(&item.path.to_string_lossy()),
relative_path: cstring_new(&item.relative_path),
file_name: cstring_new(&item.file_name),
git_status: cstring_new(format_git_status(item.git_status)),
size: item.size,
modified: item.modified,
access_frecency_score: item.access_frecency_score,
modification_frecency_score: item.modification_frecency_score,
total_frecency_score: item.total_frecency_score,
git_status: format_git_status(item.git_status).to_string(),
is_binary: item.is_binary,
}
}
}
/// Score for JSON serialization
#[derive(Debug, Serialize)]
pub struct ScoreJson {
impl FffFileItem {
/// ## Safety
/// All string pointers must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.path.is_null() {
drop(CString::from_raw(self.path));
}
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
}
}
}
/// Score breakdown for a search result.
#[repr(C)]
pub struct FffScore {
pub total: i32,
pub base_score: i32,
pub filename_bonus: i32,
@@ -156,12 +126,12 @@ pub struct ScoreJson {
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub exact_match: bool,
pub match_type: String,
pub match_type: *mut c_char,
}
impl ScoreJson {
pub fn from_score(score: &Score) -> Self {
ScoreJson {
impl From<&Score> for FffScore {
fn from(score: &Score) -> Self {
FffScore {
total: score.total,
base_score: score.base_score,
filename_bonus: score.filename_bonus,
@@ -171,234 +141,389 @@ impl ScoreJson {
current_file_penalty: score.current_file_penalty,
combo_match_boost: score.combo_match_boost,
exact_match: score.exact_match,
match_type: score.match_type.to_string(),
match_type: cstring_new(score.match_type),
}
}
}
/// Location for JSON serialization
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum LocationJson {
#[serde(rename = "line")]
Line { line: i32 },
#[serde(rename = "position")]
Position { line: i32, col: i32 },
#[serde(rename = "range")]
Range {
start: PositionJson,
end: PositionJson,
},
impl FffScore {
/// ## Safety
/// `match_type` must have been allocated by `CString::into_raw`.
pub unsafe fn free_strings(&mut self) {
unsafe {
if !self.match_type.is_null() {
drop(CString::from_raw(self.match_type));
}
}
}
}
#[derive(Debug, Serialize)]
pub struct PositionJson {
/// Location parsed from a query string (e.g. `"file.ts:42:10"`).
///
/// `tag` encodes the variant:
/// 0 = no location,
/// 1 = line only (`line` is set),
/// 2 = position (`line` + `col`),
/// 3 = range (`line`/`col` = start, `end_line`/`end_col` = end).
#[repr(C)]
pub struct FffLocation {
pub tag: u8,
pub line: i32,
pub col: i32,
pub end_line: i32,
pub end_col: i32,
}
impl LocationJson {
pub fn from_location(loc: &Location) -> Self {
impl From<Option<&Location>> for FffLocation {
fn from(loc: Option<&Location>) -> Self {
match loc {
Location::Line(line) => LocationJson::Line { line: *line },
Location::Position { line, col } => LocationJson::Position {
None => FffLocation {
tag: 0,
line: 0,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Line(line)) => FffLocation {
tag: 1,
line: *line,
col: 0,
end_line: 0,
end_col: 0,
},
Some(Location::Position { line, col }) => FffLocation {
tag: 2,
line: *line,
col: *col,
end_line: 0,
end_col: 0,
},
Location::Range { start, end } => LocationJson::Range {
start: PositionJson {
line: start.0,
col: start.1,
},
end: PositionJson {
line: end.0,
col: end.1,
},
Some(Location::Range { start, end }) => FffLocation {
tag: 3,
line: start.0,
col: start.1,
end_line: end.0,
end_col: end.1,
},
}
}
}
/// Search result for JSON serialization
#[derive(Debug, Serialize)]
pub struct SearchResultJson {
pub items: Vec<FileItemJson>,
pub scores: Vec<ScoreJson>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<LocationJson>,
/// Search result returned by `fff_search`.
///
/// The caller must free this with `fff_free_search_result`.
#[repr(C)]
pub struct FffSearchResult {
/// Pointer to a heap-allocated array of `FffFileItem` (length = `count`).
pub items: *mut FffFileItem,
/// Pointer to a heap-allocated array of `FffScore` (length = `count`).
pub scores: *mut FffScore,
/// Number of items/scores in the arrays.
pub count: u32,
/// Total number of files that matched the query.
pub total_matched: u32,
/// Total number of indexed files.
pub total_files: u32,
/// Location parsed from the query string.
pub location: FffLocation,
}
impl SearchResultJson {
pub fn from_search_result(result: &SearchResult) -> Self {
SearchResultJson {
items: result
.items
.iter()
.map(|item| FileItemJson::from_file_item(item))
.collect(),
scores: result.scores.iter().map(ScoreJson::from_score).collect(),
total_matched: result.total_matched,
total_files: result.total_files,
location: result.location.as_ref().map(LocationJson::from_location),
}
impl FffSearchResult {
/// Convert a core `SearchResult` into a heap-allocated `FffSearchResult`.
pub fn from_core(result: &SearchResult) -> *mut Self {
let items: Vec<FffFileItem> = result.items.iter().map(|i| FffFileItem::from(*i)).collect();
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
let count = items.len() as u32;
let (items_ptr, _) = vec_to_raw(items);
let (scores_ptr, _) = vec_to_raw(scores);
Box::into_raw(Box::new(FffSearchResult {
items: items_ptr,
scores: scores_ptr,
count,
total_matched: result.total_matched as u32,
total_files: result.total_files as u32,
location: FffLocation::from(result.location.as_ref()),
}))
}
}
// ============================================================================
// Multi-grep (Aho-Corasick multi-pattern) types
// ============================================================================
// ---------------------------------------------------------------------------
// Grep result 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>,
/// A byte range within a matched line, used for highlighting.
#[repr(C)]
pub struct FffMatchRange {
pub start: u32,
pub end: u32,
}
// ============================================================================
// Grep (live search) types
// ============================================================================
/// Grep search options (JSON-deserializable)
#[derive(Debug, Default, Deserialize)]
pub struct GrepSearchOptionsJson {
/// Maximum file size to search (bytes, default: 10MB)
pub max_file_size: Option<u64>,
/// Maximum matches per file (default: 200)
pub max_matches_per_file: Option<usize>,
/// Smart case: case-insensitive if query is lowercase (default: true)
pub smart_case: Option<bool>,
/// File-based pagination offset (default: 0)
pub file_offset: Option<usize>,
/// Maximum matches to return (default: 50)
pub page_limit: Option<usize>,
/// Search mode: "plain", "regex", or "fuzzy" (default: "plain")
pub mode: Option<String>,
/// Time budget in milliseconds, 0 = unlimited (default: 0)
pub time_budget_ms: Option<u64>,
/// Number of context lines before each match (default: 0)
pub before_context: Option<usize>,
/// Number of context lines after each match (default: 0)
pub after_context: Option<usize>,
/// Whether to classify matches as definition lines (default: false)
pub classify_definitions: Option<bool>,
}
/// A single grep match for JSON serialization
#[derive(Debug, Serialize)]
pub struct GrepMatchJson {
/// File metadata
pub path: String,
pub relative_path: String,
pub file_name: String,
pub git_status: String,
/// A single grep match with file and line information.
///
/// All string fields and arrays are heap-allocated. Free the parent
/// `FffGrepResult` with `fff_free_grep_result` to release everything.
#[repr(C)]
pub struct FffGrepMatch {
// -- pointers (8 bytes each) --
pub path: *mut c_char,
pub relative_path: *mut c_char,
pub file_name: *mut c_char,
pub git_status: *mut c_char,
pub line_content: *mut c_char,
pub match_ranges: *mut FffMatchRange,
pub context_before: *mut *mut c_char,
pub context_after: *mut *mut c_char,
// -- 8-byte numeric fields --
pub size: u64,
pub modified: u64,
pub is_binary: bool,
pub total_frecency_score: i64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
/// Match metadata
pub line_number: u64,
pub col: usize,
pub byte_offset: u64,
pub line_content: String,
/// Byte offset pairs (start, end) within line_content for highlighting
pub match_ranges: Vec<[u32; 2]>,
/// Fuzzy match score (only in fuzzy mode)
#[serde(skip_serializing_if = "Option::is_none")]
pub fuzzy_score: Option<u16>,
/// Whether the matched line is a code definition (struct, fn, class, etc.)
// -- 4-byte fields --
pub col: u32,
pub match_ranges_count: u32,
pub context_before_count: u32,
pub context_after_count: u32,
// -- 2-byte fields --
pub fuzzy_score: u16,
// -- 1-byte fields --
pub has_fuzzy_score: bool,
pub is_binary: bool,
pub is_definition: bool,
/// Lines before the match (context)
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_before: Vec<String>,
/// Lines after the match (context)
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_after: Vec<String>,
}
impl GrepMatchJson {
pub fn from_grep_match(m: &GrepMatch, file: &FileItem) -> Self {
GrepMatchJson {
path: file.path.to_string_lossy().to_string(),
relative_path: file.relative_path.clone(),
file_name: file.file_name.clone(),
git_status: format_git_status(file.git_status).to_string(),
impl FffGrepMatch {
fn from_core_with_file(m: &GrepMatch, file: &FileItem) -> Self {
let ranges: Vec<FffMatchRange> = m
.match_byte_offsets
.iter()
.map(|&(start, end)| FffMatchRange { start, end })
.collect();
let (match_ranges, match_ranges_count) = vec_to_raw(ranges);
let (context_before, context_before_count) = strings_to_raw(&m.context_before);
let (context_after, context_after_count) = strings_to_raw(&m.context_after);
let (has_fuzzy_score, fuzzy_score) = match m.fuzzy_score {
Some(s) => (true, s),
None => (false, 0),
};
FffGrepMatch {
path: cstring_new(&file.path.to_string_lossy()),
relative_path: cstring_new(&file.relative_path),
file_name: cstring_new(&file.file_name),
git_status: cstring_new(format_git_status(file.git_status)),
line_content: cstring_new(&m.line_content),
match_ranges,
context_before,
context_after,
size: file.size,
modified: file.modified,
is_binary: file.is_binary,
total_frecency_score: file.total_frecency_score,
access_frecency_score: file.access_frecency_score,
modification_frecency_score: file.modification_frecency_score,
line_number: m.line_number,
col: m.col,
byte_offset: m.byte_offset,
line_content: m.line_content.clone(),
match_ranges: m
.match_byte_offsets
.iter()
.map(|&(start, end)| [start, end])
.collect(),
fuzzy_score: m.fuzzy_score,
col: m.col as u32,
match_ranges_count,
context_before_count,
context_after_count,
fuzzy_score,
has_fuzzy_score,
is_binary: file.is_binary,
is_definition: m.is_definition,
context_before: m.context_before.clone(),
context_after: m.context_after.clone(),
}
}
/// ## Safety
/// All pointers must have been allocated by the corresponding `from_core`.
pub unsafe fn free_fields(&mut self) {
unsafe {
if !self.path.is_null() {
drop(CString::from_raw(self.path));
}
if !self.relative_path.is_null() {
drop(CString::from_raw(self.relative_path));
}
if !self.file_name.is_null() {
drop(CString::from_raw(self.file_name));
}
if !self.git_status.is_null() {
drop(CString::from_raw(self.git_status));
}
if !self.line_content.is_null() {
drop(CString::from_raw(self.line_content));
}
if !self.match_ranges.is_null() {
drop(Vec::from_raw_parts(
self.match_ranges,
self.match_ranges_count as usize,
self.match_ranges_count as usize,
));
}
free_cstring_array(self.context_before, self.context_before_count);
free_cstring_array(self.context_after, self.context_after_count);
}
}
}
/// Grep result for JSON serialization
#[derive(Debug, Serialize)]
pub struct GrepResultJson {
pub items: Vec<GrepMatchJson>,
pub total_matched: usize,
pub total_files_searched: usize,
pub total_files: usize,
pub filtered_file_count: usize,
pub next_file_offset: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub regex_fallback_error: Option<String>,
/// Grep result returned by `fff_live_grep` and `fff_multi_grep`.
///
/// The caller must free this with `fff_free_grep_result`.
#[repr(C)]
pub struct FffGrepResult {
/// Pointer to a heap-allocated array of `FffGrepMatch` (length = `count`).
pub items: *mut FffGrepMatch,
/// Number of matches in the `items` array.
pub count: u32,
/// Total number of matches (always equal to `count`).
pub total_matched: u32,
/// Number of files actually opened and searched in this call.
pub total_files_searched: u32,
/// Total number of indexed files (before any filtering).
pub total_files: u32,
/// Number of files eligible for search after filtering.
pub filtered_file_count: u32,
/// File offset for the next page. 0 if all files have been searched.
pub next_file_offset: u32,
/// Regex compilation error when falling back to literal matching. Null if none.
pub regex_fallback_error: *mut c_char,
}
impl GrepResultJson {
pub fn from_grep_result(result: &GrepResult) -> Self {
GrepResultJson {
items: result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
GrepMatchJson::from_grep_match(m, file)
})
.collect(),
total_matched: result.matches.len(),
total_files_searched: result.total_files_searched,
total_files: result.total_files,
filtered_file_count: result.filtered_file_count,
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.clone(),
}
impl FffGrepResult {
/// Convert a core `GrepResult` into a heap-allocated `FffGrepResult`.
pub fn from_core(result: &GrepResult) -> *mut Self {
let items: Vec<FffGrepMatch> = result
.matches
.iter()
.map(|m| {
let file = result.files[m.file_index];
FffGrepMatch::from_core_with_file(m, file)
})
.collect();
let (items_ptr, count) = vec_to_raw(items);
Box::into_raw(Box::new(FffGrepResult {
items: items_ptr,
count,
total_matched: result.matches.len() as u32,
total_files_searched: result.total_files_searched as u32,
total_files: result.total_files as u32,
filtered_file_count: result.filtered_file_count as u32,
next_file_offset: result.next_file_offset as u32,
regex_fallback_error: match &result.regex_fallback_error {
Some(e) => cstring_new(e),
None => ptr::null_mut(),
},
}))
}
}
/// Result envelope returned by all `fff_*` functions.
///
/// Heap-allocated — the caller must free it with `fff_free_result`.
///
/// Depending on the function, the payload is delivered through different fields:
///
/// | Function | Payload field | Type |
/// |----------------------------|---------------|-------------------------------|
/// | `fff_create_instance` | `handle` | opaque instance pointer |
/// | `fff_search` | `handle` | `*mut FffSearchResult` |
/// | `fff_live_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_multi_grep` | `handle` | `*mut FffGrepResult` |
/// | `fff_get_scan_progress` | `handle` | `*mut FffScanProgress` |
/// | `fff_health_check` | `handle` | `*mut c_char` (JSON string) |
/// | `fff_get_historical_query` | `handle` | `*mut c_char` (string or null)|
/// | `fff_wait_for_scan` | `int_value` | 1 = completed, 0 = timed out |
/// | `fff_track_query` | `int_value` | 1 = success, 0 = failure |
/// | `fff_refresh_git_status` | `int_value` | number of files updated |
/// | `fff_scan_files` | (none) | success flag only |
/// | `fff_restart_index` | (none) | success flag only |
///
/// On failure, `success` is false and `error` contains the message.
///
/// **Important:** `fff_free_result` frees `error` but does **not** free `handle`.
/// The caller must free the handle with the appropriate function
/// (`fff_destroy`, `fff_free_search_result`, `fff_free_grep_result`,
/// `fff_free_string`, etc.).
#[repr(C)]
pub struct FffResult {
/// Whether the operation succeeded.
pub success: bool,
/// Error message on failure. Null on success.
pub error: *mut c_char,
/// Opaque pointer payload (instance handle, typed result struct, or string). May be null.
pub handle: *mut c_void,
/// Integer payload for simple return values (bool as 0/1, counts, etc.).
pub int_value: i64,
}
impl FffResult {
/// Create a successful result with no payload, returned as heap pointer.
pub fn ok_empty() -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
/// Create a successful result with an integer value.
pub fn ok_int(value: i64) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: ptr::null_mut(),
int_value: value,
}))
}
/// Create a successful result carrying an opaque pointer (handle, typed struct, or string).
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle,
int_value: 0,
}))
}
/// Create a successful result carrying a C string in the `handle` field.
/// The caller must free it with `fff_free_string`.
pub fn ok_string(s: &str) -> *mut Self {
let cstr = CString::new(s).unwrap_or_default().into_raw();
Box::into_raw(Box::new(FffResult {
success: true,
error: ptr::null_mut(),
handle: cstr as *mut c_void,
int_value: 0,
}))
}
/// Create an error result, returned as heap pointer.
pub fn err(error: &str) -> *mut Self {
Box::into_raw(Box::new(FffResult {
success: false,
error: CString::new(error).unwrap_or_default().into_raw(),
handle: ptr::null_mut(),
int_value: 0,
}))
}
}
// ---------------------------------------------------------------------------
// Scan progress
// ---------------------------------------------------------------------------
/// Scan progress returned by `fff_get_scan_progress`.
///
/// The caller must free this with `fff_free_scan_progress`.
#[repr(C)]
pub struct FffScanProgress {
pub scanned_files_count: u64,
pub is_scanning: bool,
}
+429 -226
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -1,9 +1,10 @@
[package]
name = "fff-core"
version = "0.1.0"
name = "fff-search"
version = "0.2.4"
edition = "2024"
description = "High-performance file finder core library"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
description = "Faboulous & Fast File Finder - a fast and extremely correct file finder SDK with typo resistance, SIMD, prefiltering, and more"
[lib]
path = "src/lib.rs"
@@ -18,15 +19,12 @@ ffi = []
zlob = ["dep:zlob", "fff-query-parser/zlob"]
[dependencies]
# Workspace dependencies
ahash = { workspace = true }
rayon = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff-query-parser = { path = "../fff-query-parser", default-features = false }
fff-query-parser = { workspace = true , version = "0.2.4" }
# External dependencies
bindet = { workspace = true }
@@ -37,7 +35,7 @@ git2 = { workspace = true }
glidesort = { workspace = true }
globset = { workspace = true }
grep-matcher = { workspace = true }
grep-searcher = { workspace = true }
fff-grep = { workspace = true , version = "0.2.4" }
aho-corasick = "1"
memchr = "2"
heed = { workspace = true }
+26
View File
@@ -0,0 +1,26 @@
# fff
fff is a file search toolkit. It is faster than ripgrep and fzf and designed for a long running applications like file editors, ai agents, or file exploerers.
## Features
- Fuzzy file name search
- Typo resistance
- Frecency and query history ranking
- Native git support via libgit
- Advanced ranking
- Grep functionality with SIMD optimized plain matcher and regex
- Multi grep using aho-corasick algorithm
- Efficient memory mapping for file system
- Cross platform support (Linux, Windows, MacOS)
- Advnaced constraints syntax allowing to prefilter based on git status, glob, extension, size, timing and more
## Performance
FFF is designed for high performance and low latency. SIMD optimized where needed, parallelized for multi core systems, efficient sorting and ranking algorithms, memaps and much more.
On MacOS FFF is about 20-50 times faster than ripgrep for content search and around 10 times faster than fzf for file name search.
## Documentation
Refer rust docs https://docs.rs/crate/fff-search/latest
+55 -16
View File
@@ -8,14 +8,30 @@ use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::{Level, debug, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
/// Owns the file-system watcher and guarantees that all background threads
/// are fully joined before `stop()` / `Drop` returns.
///
/// Architecture:
/// - The debouncer (and its internal watcher) live inside an **owner thread**
/// that we spawn and hold the `JoinHandle` for.
/// - `stop()` sets a flag, unparks the owner thread, and **joins** it.
/// - Inside the owner thread, `Debouncer::stop()` is called which joins the
/// debouncer's event-processing thread.
/// - On Windows an additional short sleep is added after `Debouncer::stop()`
/// because `notify`'s `ReadDirectoryChangesWatcher` discards its thread
/// `JoinHandle`, so we cannot join it directly. The watcher's `Drop` does
/// signal the thread via semaphore so it exits almost immediately, but we
/// need to give the OS a moment to reclaim it.
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
stop_signal: Arc<AtomicBool>,
owner_thread: Option<std::thread::JoinHandle<()>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
@@ -43,8 +59,33 @@ impl BackgroundWatcher {
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
info!("Background file watcher initialized successfully");
let stop_signal = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop_signal);
// The owner thread keeps the debouncer alive and ensures proper
// cleanup: `Debouncer::stop()` joins its internal thread, then the
// watcher `Drop` signals its I/O thread to exit.
let owner_thread = std::thread::Builder::new()
.name("fff-watcher-owner".into())
.spawn(move || {
while !stop_clone.load(Ordering::Acquire) {
std::thread::park_timeout(Duration::from_secs(1));
}
// Debouncer::stop() joins the debouncer's event thread, then
// drops the watcher (whose Drop signals the I/O thread).
debouncer.stop();
// On Windows the notify crate discards the ReadDirectoryChangesW
// thread's JoinHandle — we cannot join it. Its Drop signals the
// thread via semaphore so it exits almost immediately; give the
// OS a moment to fully reclaim it.
#[cfg(windows)]
std::thread::sleep(Duration::from_millis(250));
})
.expect("failed to spawn fff-watcher-owner thread");
Ok(Self {
debouncer: Arc::new(Mutex::new(Some(debouncer))),
stop_signal,
owner_thread: Some(owner_thread),
})
}
@@ -130,25 +171,23 @@ impl BackgroundWatcher {
Ok(debouncer)
}
pub fn stop(&self) {
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
drop(debouncer);
info!("Background file watcher stopped successfully");
} else {
error!("Failed to stop background watcher");
pub fn stop(&mut self) {
self.stop_signal.store(true, Ordering::Release);
if let Some(handle) = self.owner_thread.take() {
handle.thread().unpark();
if let Err(e) = handle.join() {
error!("Watcher owner thread panicked: {:?}", e);
}
}
info!("Background file watcher stopped successfully");
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
if let Some(debouncer) = debouncer_guard.take() {
drop(debouncer);
}
} else {
error!("Failed to acquire debouncer lock to drop");
}
self.stop();
}
}
+139 -61
View File
@@ -1,8 +1,41 @@
//! Core file picker: filesystem indexing, background watching, and fuzzy search.
//!
//! [`FilePicker`] is the central component of fff-search. It:
//!
//! 1. **Indexes** a directory tree in a background thread, collecting every
//! non-ignored file into a path-sorted `Vec<FileItem>`.
//! 2. **Watches** the filesystem via the `notify` crate, applying
//! create/modify/delete events to the index in real time.
//! 3. **Owns files**: Provides a values for search and provides a good entry point for
//! fuzzy search and live grep
//!
//! # Lifecycle
//!
//! ```text
//! new_with_shared_state()
//! │
//! ├─> background scan thread ──> populates SharedPicker
//! └─> file-system watcher ──> live updates SharedPicker
//!
//! fuzzy_search() <── static, borrows &[FileItem]
//! grep() <── static, borrows &[FileItem] (live content search)
//! trigger_rescan() <── synchronous re-index
//! cancel() <── shuts down background work
//! ```
//!
//! # Thread Safety
//!
//! `FilePicker` itself is **not** `Sync`!
//! all concurrent access goes through [`SharedPicker`](crate::SharedPicker) .
//! The background scanner and watcher acquire write locks only when mutating
//! the file index, so read-heavy search workloads rarely contend.
use crate::background_watcher::BackgroundWatcher;
use crate::error::Error;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
use crate::query_tracker::QueryMatchEntry;
use crate::grep::{GrepResult, GrepSearchOptions, grep_search};
use crate::query_tracker::QueryTracker;
use crate::score::match_and_score_files;
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
use crate::{SharedFrecency, SharedPicker};
@@ -32,31 +65,15 @@ impl FFFMode {
}
}
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
#[inline]
fn detect_binary(path: &Path, size: u64) -> bool {
// Empty files are not binary
if size == 0 {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = std::io::BufReader::with_capacity(1024, file);
let mut buf = [0u8; 512];
let n = reader.read(&mut buf).unwrap_or(0);
buf[..n].contains(&0)
}
#[derive(Debug, Clone, Copy)]
/// Configuration for a single fuzzy search invocation.
///
/// Passed to [`FilePicker::fuzzy_search`] to control threading, pagination,
/// and scoring behavior.
#[derive(Debug, Clone, Copy, Default)]
pub struct FuzzySearchOptions<'a> {
pub max_threads: usize,
pub current_file: Option<&'a str>,
pub project_path: Option<&'a Path>,
pub last_same_query_match: Option<&'a QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
@@ -196,6 +213,10 @@ impl FileItem {
}
}
/// The main file picker engine storage
///
/// It maintains an in memory index of all the files that are resent in the file system
/// and borrows them to perform the search
pub struct FilePicker {
base_path: PathBuf,
sync_data: FileSync,
@@ -310,22 +331,16 @@ impl FilePicker {
/// Perform fuzzy search on files with a pre-parsed query.
///
/// The query should be parsed using `QueryParser::parse()` before calling this function.
/// This allows the caller to handle location parsing and other preprocessing.
/// The query should be parsed using [`FFFQuery`]::parse() before calling
/// this function. If a [`QueryTracker`] is provided, the search will
/// automatically look up the last selected file for this query and apply
/// combo-boost scoring.
///
/// # Arguments
/// * `files` - Slice of files to search
/// * `query` - The raw query string (used for max_typos calculation and debugging)
/// * `parsed` - Pre-parsed query result (can be None for simple single-token queries)
/// * `options` - Search options including pagination, threading, and scoring parameters
///
/// # Returns
/// SearchResult containing matched files, scores, and location information
pub fn fuzzy_search<'a>(
pub fn fuzzy_search<'a, 'q>(
files: &'a [FileItem],
query: &'a str,
parsed: Option<FFFQuery<'a>>,
options: FuzzySearchOptions<'a>,
query: &'q FFFQuery<'q>,
query_tracker: Option<&QueryTracker>,
options: FuzzySearchOptions<'q>,
) -> SearchResult<'a> {
let max_threads = if options.max_threads == 0 {
std::thread::available_parallelism()
@@ -334,9 +349,9 @@ impl FilePicker {
} else {
options.max_threads
};
debug!(
?query,
parsed_is_some = parsed.is_some(),
raw_query = ?query.raw_query,
pagination = ?options.pagination,
?max_threads,
current_file = ?options.current_file,
@@ -344,41 +359,48 @@ impl FilePicker {
);
let total_files = files.len();
// Extract location from parsed query
let location = parsed.as_ref().and_then(|p| p.location);
let location = query.location;
// Get effective query for max_typos calculation (without location suffix)
let effective_query = match &parsed {
Some(p) => match &p.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query.trim(),
},
None => query.trim(),
let effective_query = match &query.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query.raw_query.trim(),
};
// small queries with a large number of results can match absolutely everything
let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
// Look up the last file selected for this query (combo-boost scoring)
let last_same_query_entry =
query_tracker
.zip(options.project_path)
.and_then(|(tracker, project_path)| {
tracker
.get_last_query_entry(
query.raw_query,
project_path,
options.min_combo_count,
)
.ok()
.flatten()
});
let context = ScoringContext {
raw_query: query,
parsed_query: parsed,
project_path: options.project_path,
query,
max_typos,
max_threads,
project_path: options.project_path,
current_file: options.current_file,
last_same_query_match: options.last_same_query_match,
last_same_query_match: last_same_query_entry,
combo_boost_score_multiplier: options.combo_boost_score_multiplier,
min_combo_count: options.min_combo_count,
pagination: options.pagination,
};
let time = std::time::Instant::now();
let (items, scores, total_matched) = match_and_score_files(files, &context);
debug!(
info!(
?query,
completed_in = ?time.elapsed(),
total_matched,
@@ -396,6 +418,16 @@ impl FilePicker {
}
}
/// Perform a live grep search across indexed files with a pre-parsed query.
pub fn grep<'a>(
files: &'a [FileItem],
query: &FFFQuery<'_>,
options: &GrepSearchOptions,
) -> GrepResult<'a> {
grep_search(files, query, options)
}
// Returns an ongoing or finisshed scan progress
pub fn get_scan_progress(&self) -> ScanProgress {
let scanned_count = self.scanned_files_count.load(Ordering::Relaxed);
let is_scanning = self.is_scanning.load(Ordering::Relaxed);
@@ -614,13 +646,13 @@ impl FilePicker {
.retain_files(|file| !file.path.starts_with(dir_path))
}
/// We use this to prevent any substantial background threads from acquiring the locks
/// 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() {
if let Some(mut watcher) = self.background_watcher.take() {
watcher.stop();
}
}
@@ -664,6 +696,7 @@ impl FilePicker {
Ok(())
}
/// Quick way to check if scan is going without acquiring a lock for [Self::get_scan_progress]
pub fn is_scan_active(&self) -> bool {
self.is_scanning.load(Ordering::Relaxed)
}
@@ -673,12 +706,39 @@ impl FilePicker {
pub fn scan_signal(&self) -> Arc<AtomicBool> {
Arc::clone(&self.is_scanning)
}
/// Block the current thread until the background filesystem scan finishes.
///
/// Briefly acquires a read lock on the shared picker to obtain the scan
/// signal, then drops the lock and polls without holding it — so the
/// background thread can still write to the index.
///
/// Returns immediately if no picker has been initialised yet.
pub fn wait_for_scan(shared_picker: &SharedPicker) {
let signal = {
let guard = shared_picker.read().expect("shared picker lock poisoned");
match guard.as_ref() {
Some(picker) => picker.scan_signal(),
None => return,
}
};
while signal.load(Ordering::Acquire) {
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
/// A point-in-time snapshot of the file-scanning progress.
///
/// Returned by [`FilePicker::get_scan_progress`]. Useful for displaying
/// a progress indicator while the initial scan is running.
#[allow(unused)]
#[derive(Debug, Clone)]
pub struct ScanProgress {
/// Number of files indexed so far.
pub scanned_files_count: usize,
/// `true` while the background scan thread is still running.
pub is_scanning: bool,
}
@@ -798,11 +858,10 @@ fn warmup_mmaps(files: &[FileItem]) {
return;
}
if let Some(mmap) = file.get_mmap() {
// Read the first byte to trigger the initial page fault, which
// causes the kernel to start readahead for subsequent pages.
// This is cheaper than madvise and portable across all platforms.
let _ = std::hint::black_box(mmap.first());
if let Some(content) = file.get_mmap() {
// Read the first byte to trigger the initial page fault (mmap)
// or ensure the content is cached (Windows buffer).
let _ = std::hint::black_box(content.first());
warmed.fetch_add(1, Ordering::Relaxed);
}
@@ -940,3 +999,22 @@ fn is_git_file(path: &Path) -> bool {
}
})
}
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
#[inline]
fn detect_binary(path: &Path, size: u64) -> bool {
// Empty files are not binary
if size == 0 {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = std::io::BufReader::with_capacity(1024, file);
let mut buf = [0u8; 512];
let n = reader.read(&mut buf).unwrap_or(0);
buf[..n].contains(&0)
}
+27 -16
View File
@@ -1,6 +1,7 @@
use crate::db_healthcheck::DbHealthChecker;
use crate::error::{Error, Result};
use crate::file_picker::FFFMode;
use crate::{SharedFrecency, error::Error, git::is_modified_status};
use crate::{SharedFrecency, git::is_modified_status};
use heed::{Database, Env, EnvOpenOptions};
use heed::{
EnvFlags,
@@ -47,7 +48,7 @@ impl DbHealthChecker for FrecencyTracker {
&self.env
}
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let count = self.db.len(&rtxn).map_err(Error::DbRead)?;
@@ -56,8 +57,10 @@ impl DbHealthChecker for FrecencyTracker {
}
impl FrecencyTracker {
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self> {
let db_path = db_path.as_ref();
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
@@ -82,16 +85,24 @@ impl FrecencyTracker {
}
/// Spawns a background thread to purge stale frecency entries and compact the database.
/// Run it once in a while to purge old pages and keep DB file size reasonable.
///
/// 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()
/// It's okay to not join this thread since it acquires locks for the db access
///
/// ```
/// use fff_search::frecency::FrecencyTracker;
/// use fff_search::SharedFrecency;
/// let shared_frecency: SharedFrecency = Default::default();
/// let _ = FrecencyTracker::spawn_gc(shared_frecency, "/path/to/frecency_db".into(), true).ok();
/// ```
pub fn spawn_gc(
shared: SharedFrecency,
db_path: String,
use_unsafe_no_lock: bool,
) -> Result<std::thread::JoinHandle<()>> {
Ok(std::thread::Builder::new()
.name("fff-frecency-gc".into())
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))
.ok();
.spawn(move || Self::run_frecency_gc(shared, db_path, use_unsafe_no_lock))?)
}
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
@@ -223,7 +234,7 @@ impl FrecencyTracker {
/// 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> {
fn purge_stale_entries(&self) -> Result<(usize, usize)> {
let now = self.get_now();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
@@ -275,7 +286,7 @@ impl FrecencyTracker {
Ok((to_delete.len(), to_update.len()))
}
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let key_hash = Self::path_to_hash_bytes(path)?;
@@ -289,7 +300,7 @@ impl FrecencyTracker {
.as_secs()
}
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32], Error> {
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32]> {
let Some(key) = path.to_str() else {
return Err(Error::InvalidPath(path.to_path_buf()));
};
@@ -299,13 +310,13 @@ impl FrecencyTracker {
/// 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> {
pub fn seconds_since_last_access(&self, path: &Path) -> Result<Option<u64>> {
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> {
pub fn track_access(&self, path: &Path) -> Result<()> {
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
let key_hash = Self::path_to_hash_bytes(path)?;
+122 -41
View File
@@ -9,10 +9,10 @@ use crate::constraints::apply_constraints;
use crate::sort_buffer::sort_with_buffer;
use crate::types::FileItem;
use aho_corasick::AhoCorasick;
use fff_grep::lines::{self, LineStep};
use fff_grep::{Searcher, SearcherBuilder, Sink, SinkMatch};
use fff_query_parser::{Constraint, FFFQuery, GrepConfig, QueryParser};
use grep_matcher::{Match, Matcher, NoCaptures, NoError};
use grep_searcher::lines::{self, LineStep};
use grep_searcher::{Searcher, SearcherBuilder, Sink, SinkMatch};
use rayon::prelude::*;
use smallvec::SmallVec;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -165,6 +165,67 @@ pub fn has_regex_metacharacters(text: &str) -> bool {
regex::escape(text) != text
}
/// Check if `text` contains `\n` that is NOT preceded by another `\`.
///
/// `\n` → true (user wants multiline search)
/// `\\n` → false (escaped backslash followed by literal `n`, e.g. `\\nvim-data`)
#[inline]
fn has_unescaped_newline_escape(text: &str) -> bool {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len().saturating_sub(1) {
if bytes[i] == b'\\' {
if bytes[i + 1] == b'n' {
// Count consecutive backslashes ending at position i
let mut backslash_count = 1;
while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
backslash_count += 1;
}
// Odd number of backslashes before 'n' → real \n escape
if backslash_count % 2 == 1 {
return true;
}
}
// Skip past the escaped character
i += 2;
} else {
i += 1;
}
}
false
}
/// Replace only unescaped `\n` sequences with real newlines.
///
/// `\n` → newline character
/// `\\n` → preserved as-is (literal backslash + `n`)
fn replace_unescaped_newline_escapes(text: &str) -> String {
let bytes = text.as_bytes();
let mut result = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
if bytes[i + 1] == b'n' {
let mut backslash_count = 1;
while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
backslash_count += 1;
}
if backslash_count % 2 == 1 {
result.push(b'\n');
i += 2;
continue;
}
}
result.push(bytes[i]);
i += 1;
} else {
result.push(bytes[i]);
i += 1;
}
}
String::from_utf8(result).unwrap_or_else(|_| text.to_string())
}
/// Controls how the grep pattern is interpreted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GrepMode {
@@ -660,7 +721,7 @@ impl Sink for PlainTextSink<'_> {
Ok(true)
}
fn finish(&mut self, _: &Searcher, _: &grep_searcher::SinkFinish) -> Result<(), Self::Error> {
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
@@ -718,7 +779,7 @@ impl Sink for RegexSink<'_> {
Ok(true)
}
fn finish(&mut self, _: &Searcher, _: &grep_searcher::SinkFinish) -> Result<(), Self::Error> {
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
@@ -801,7 +862,7 @@ impl Sink for AhoCorasickSink<'_> {
Ok(true)
}
fn finish(&mut self, _: &Searcher, _: &grep_searcher::SinkFinish) -> Result<(), Self::Error> {
fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
Ok(())
}
}
@@ -1018,8 +1079,8 @@ where
return None;
}
let mmap = file.get_mmap()?;
let file_matches = search_file(&mmap[..], options.max_matches_per_file);
let content = file.get_mmap()?;
let file_matches = search_file(content, options.max_matches_per_file);
if file_matches.is_empty() {
return None;
@@ -1303,8 +1364,7 @@ fn fuzzy_grep_search<'a>(
return None;
}
let mmap = file.get_mmap()?;
let file_bytes = &mmap[..];
let file_bytes = file.get_mmap()?;
// File-level prefilter: check if enough distinct needle chars
// exist anywhere in the file bytes. Uses memchr for speed.
@@ -1496,8 +1556,7 @@ fn fuzzy_grep_search<'a>(
/// frecency for the "welcome state" UI.
pub fn grep_search<'a>(
files: &'a [FileItem],
raw_query: &str,
query: Option<&FFFQuery<'_>>,
query: &FFFQuery<'_>,
options: &GrepSearchOptions,
) -> GrepResult<'a> {
let total_files = files.len();
@@ -1507,36 +1566,23 @@ pub fn grep_search<'a>(
// removed. All non-constraint text tokens are collected and joined with
// spaces to form the grep pattern:
// "name = *.rs someth" -> grep "name = someth" with constraint Extension("rs")
let constraints_from_query: &[fff_query_parser::Constraint<'_>];
let constraints_from_query = &query.constraints[..];
let grep_text = match query {
Some(p) => {
constraints_from_query = &p.constraints[..];
p.grep_text()
}
None => {
constraints_from_query = &[];
// Single-token query (parser returned None). If the token is a
// backslash-escaped constraint (e.g. `\*.rs`, `\/src/`, `\!test`),
// strip the leading `\` so the literal text is searched. Other
// backslash sequences (e.g. `\bfoo\b` in regex mode) are left alone.
let t = raw_query.trim();
if t.starts_with('\\') && t.len() > 1 {
// Re-parse the unescaped suffix: if it would be a constraint,
// the user intended an escape; strip the backslash.
let suffix = &t[1..];
let parser = QueryParser::new(GrepConfig);
if parser
.parse(suffix)
.is_some_and(|q| !q.constraints.is_empty())
{
suffix.to_string()
} else {
t.to_string()
}
let grep_text = if !matches!(query.fuzzy_query, fff_query_parser::FuzzyQuery::Empty) {
query.grep_text()
} else {
// Constraint-only or empty query — use raw_query for backslash-escape handling.
let t = query.raw_query.trim();
if t.starts_with('\\') && t.len() > 1 {
let suffix = &t[1..];
let parser = QueryParser::new(GrepConfig);
if !parser.parse(suffix).constraints.is_empty() {
suffix.to_string()
} else {
t.to_string()
}
} else {
t.to_string()
}
};
@@ -1556,7 +1602,10 @@ pub fn grep_search<'a>(
prepare_files_to_search(files, constraints_from_query, options);
// If constraints yielded 0 files and we had a FilePath constraint,
// retry without it (the path token was likely part of the search text).
// retry without it — the filename may not exist in this repo.
// Keep the original grep_text (e.g. "ActorAuth") rather than restoring
// the raw query ("nonexistent.rs ActorAuth"), since the search term
// was correctly extracted by the parser.
if files_to_search.is_empty()
&& let Some(stripped) = strip_file_path_constraints(constraints_from_query)
{
@@ -1602,10 +1651,10 @@ pub fn grep_search<'a>(
.ok(),
};
let is_multiline = grep_text.contains("\\n");
let is_multiline = has_unescaped_newline_escape(&grep_text);
let effective_pattern = if is_multiline {
grep_text.replace("\\n", "\n")
replace_unescaped_newline_escapes(&grep_text)
} else {
grep_text.to_string()
};
@@ -1682,7 +1731,7 @@ pub fn grep_search<'a>(
}
/// Parse a grep query using the GrepConfig parser.
pub fn parse_grep_query(query: &str) -> Option<FFFQuery<'_>> {
pub fn parse_grep_query(query: &str) -> FFFQuery<'_> {
let parser = QueryParser::new(GrepConfig);
parser.parse(query)
}
@@ -1708,6 +1757,38 @@ fn strip_file_path_constraints<'a>(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unescaped_newline_detection() {
// Single \n → multiline
assert!(has_unescaped_newline_escape("foo\\nbar"));
// \\n → escaped backslash + literal n, NOT multiline
// (this is what the user types when grepping Rust source with `\\nvim`)
assert!(!has_unescaped_newline_escape("foo\\\\nvim-data"));
// Real-world: source file has literal \\AppData\\Local\\nvim-data
// (double backslash in the file, so user types double backslash)
assert!(!has_unescaped_newline_escape(
r#"format!("{}\\AppData\\Local\\nvim-data","#
));
// No \n at all
assert!(!has_unescaped_newline_escape("hello world"));
// \\\\n → even number of backslashes before n → NOT multiline
assert!(!has_unescaped_newline_escape("foo\\\\\\\\nbar"));
// \\\n → 3 backslashes: first two pair up, third + n = \n → multiline
assert!(has_unescaped_newline_escape("foo\\\\\\nbar"));
}
#[test]
fn test_replace_unescaped_newline() {
// \n → real newline
assert_eq!(replace_unescaped_newline_escapes("foo\\nbar"), "foo\nbar");
// \\n → preserved as-is
assert_eq!(
replace_unescaped_newline_escapes("foo\\\\nvim"),
"foo\\\\nvim"
);
}
#[test]
fn test_fuzzy_typo_scoring() {
+136 -28
View File
@@ -1,46 +1,154 @@
//! fff-core - High-performance file finder library
//! # FFF Search — High-performance file finder core
//!
//! This crate provides the core file indexing and fuzzy search functionality.
//! This crate provides the core search engine for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim).
//! It includes filesystem indexing with real-time watching, fuzzy matching powered
//! by [frizbee](https://docs.rs/neo_frizbee), frecency scoring backed by LMDB,
//! and multi-mode grep search.
//!
//! # State management
//! ## Architecture
//!
//! All state is instance-based. Callers create their own `SharedPicker` /
//! `SharedFrecency` / `SharedQueryTracker` and pass them into
//! `FilePicker::new_with_shared_state`. Multiple independent instances can
//! coexist in the same process.
//! - [`file_picker::FilePicker`] — Main entry point. Indexes a directory tree in a
//! background thread, maintains a sorted file list, watches the filesystem for
//! changes, and performs fuzzy search with frecency-weighted scoring.
//! - [`frecency::FrecencyTracker`] — LMDB-backed database that tracks file access
//! and modification patterns for intelligent result ranking.
//! - [`query_tracker::QueryTracker`] — Tracks search query history and provides
//! "combo-boost" scoring for repeatedly matched files.
//! - [`grep`] — Live grep search supporting regex, plain-text, and fuzzy modes
//! with optional constraint filtering.
//! - [`git`] — Git status caching and repository detection.
//!
//! ## Shared State
//!
//! [`SharedPicker`], [`SharedFrecency`], and [`SharedQueryTracker`] are
//! `Arc<RwLock<Option<T>>>` type aliases for thread-safe shared access. FFF
//! is designed for long-running processes that keep the file index in global
//! state, so these wrappers let background threads (scanner, watcher) share
//! data with the calling code safely.
//!
//! ## Quick Start
//!
//! ```
//! use fff_search::file_picker::FilePicker;
//! use fff_search::frecency::FrecencyTracker;
//! use fff_search::query_tracker::QueryTracker;
//! use fff_search::{
//! FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser,
//! SharedFrecency, SharedPicker, SharedQueryTracker,
//! };
//!
//! let shared_picker: SharedPicker = Default::default();
//! let shared_frecency: SharedFrecency = Default::default();
//! let shared_query_tracker: SharedQueryTracker = Default::default();
//!
//! let tmp = std::env::temp_dir().join("fff-doctest");
//! std::fs::create_dir_all(&tmp).unwrap();
//!
//! // 1. Optionally initialize frecency and query tracker databases
//! let frecency = FrecencyTracker::new(tmp.join("frecency"), false)?;
//! *shared_frecency.write().unwrap() = Some(frecency);
//!
//! let query_tracker = QueryTracker::new(tmp.join("queries"), false)?;
//! *shared_query_tracker.write().unwrap() = Some(query_tracker);
//!
//! // 2. Init the file picker (spawns background scan + watcher)
//! FilePicker::new_with_shared_state(
//! ".".into(),
//! /* warmup memap caches = */ false,
//! FFFMode::Ai, // use AI for ai agents, and Neovim for editors
//! shared_picker.clone(),
//! shared_frecency.clone(),
//! )?;
//!
//! // 3. Wait for scan (in real app you would like to add some tokio flavor here)
//! FilePicker::wait_for_scan(&shared_picker);
//!
//! // 4. Search: lock the picker and query tracker
//! let picker_lock_guard = shared_picker.read().unwrap();
//! let picker = picker_lock_guard.as_ref().unwrap();
//! let query_tracker_lock_guard = shared_query_tracker.read().unwrap();
//!
//! // 5. Parse the query and perform fuzzy search with frecency and combo-boost scoring
//! let parser = QueryParser::default();
//! let query = parser.parse("lib.rs");
//!
//! let results = FilePicker::fuzzy_search(
//! picker.get_files(),
//! &query,
//! query_tracker_lock_guard.as_ref(),
//! FuzzySearchOptions {
//! max_threads: 0,
//! current_file: None,
//! pagination: PaginationArgs { offset: 0, limit: 50 },
//! ..Default::default()
//! },
//! );
//!
//! assert!(results.total_matched > 0);
//! assert!(results.items.first().unwrap().path.ends_with("lib.rs"));
//!
//! let _ = std::fs::remove_dir_all(&tmp);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
mod background_watcher;
pub mod constraints;
mod constraints;
mod db_healthcheck;
mod error;
pub mod file_picker;
pub mod frecency;
pub mod git;
pub mod grep;
pub mod log;
pub mod path_utils;
pub mod query_tracker;
pub mod score;
mod score;
mod sort_buffer;
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
///
/// See [`FilePicker`](file_picker::FilePicker) for the main entry point.
pub mod file_picker;
/// Frecency (frequency + recency) database for file access scoring.
///
/// Backed by LMDB for persistent, crash-safe storage.
pub mod frecency;
/// Git status caching and repository detection utilities.
pub mod git;
/// Live grep search with regex, plain-text, and fuzzy matching modes.
///
/// Supports constraint filtering (file extensions, path segments, globs)
/// and parallel execution via rayon.
pub mod grep;
/// Tracing/logging initialization and panic hook setup.
pub mod log;
/// Path manipulation utilities: cross platform canonicalization, tilde expansion, and
/// directory distance penalties for search scoring.
pub mod path_utils;
/// Search query history tracker for combo-boost scoring.
///
/// Records which files a user selects for each query, enabling the scorer
/// to boost files that were previously chosen for similar searches.
pub mod query_tracker;
/// Core data types shared across the crate.
pub mod types;
use file_picker::FilePicker;
use frecency::FrecencyTracker;
use query_tracker::QueryTracker;
use std::sync::{Arc, RwLock};
/// Thread-safe shared handle to the [`FilePicker`] instance.
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
/// Thread-safe shared handle to the [`QueryTracker`] instance.
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
pub use db_healthcheck::{DbHealth, DbHealthChecker};
pub use error::{Error, Result};
pub use fff_query_parser::{
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
};
pub use file_picker::{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};
pub use fff_query_parser::*;
pub use file_picker::*;
pub use frecency::*;
pub use grep::*;
pub use query_tracker::*;
pub use types::*;
+16 -7
View File
@@ -1,12 +1,5 @@
//! Path utility functions for file picker scoring
use std::path::{Path, PathBuf};
/// Canonicalize a path, resolving symlinks and producing an absolute path.
///
/// On Windows, uses `dunce::canonicalize` to avoid the `\\?\` extended-length path prefix
/// that `std::fs::canonicalize` produces. Neovim cannot open paths with this prefix.
/// On other platforms, delegates directly to `std::fs::canonicalize`.
#[cfg(windows)]
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
dunce::canonicalize(path)
@@ -17,6 +10,22 @@ pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
std::fs::canonicalize(path)
}
#[cfg(windows)]
pub fn expand_tilde(path: &str) -> PathBuf {
return PathBuf::from(path);
}
#[cfg(not(windows))]
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home_dir) = dirs::home_dir()
{
return home_dir.join(stripped);
}
PathBuf::from(path)
}
/// Calculate distance penalty based on directory proximity
/// Returns a negative penalty score based on how far the candidate is from the current file
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 {
+3 -1
View File
@@ -61,8 +61,10 @@ impl DbHealthChecker for QueryTracker {
}
impl QueryTracker {
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self, Error> {
let db_path = db_path.as_ref();
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
+33 -29
View File
@@ -131,39 +131,34 @@ pub fn match_and_score_files<'a>(
return (vec![], vec![], 0);
}
let parsed = &context.parsed_query;
let working_files: FileItems<'a> = match parsed.as_ref().and_then(|p| {
if p.constraints.is_empty() {
None
} else {
apply_constraints(files, &p.constraints)
let parsed = context.query;
let working_files: FileItems<'a> = if parsed.constraints.is_empty() {
FileItems::All(files)
} else {
match apply_constraints(files, &parsed.constraints) {
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
Some(_) => {
return (vec![], vec![], 0);
}
None => FileItems::All(files),
}
}) {
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
Some(_) => {
return (vec![], vec![], 0);
}
None => FileItems::All(files),
};
let query_trimmed: &str = context.raw_query.trim();
let query_trimmed: &str = parsed.raw_query.trim();
let single_part_storage: [&str; 1] = [query_trimmed];
let fuzzy_parts: &[&str] = match parsed {
None => {
tracing::debug!("STEP 3: Query too short (<2 chars), returning frecency-sorted");
let fuzzy_parts: &[&str] = match &parsed.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
FuzzyQuery::Text(_) | FuzzyQuery::Parts(_) => {
return score_filtered_by_frecency(&working_files, context);
}
FuzzyQuery::Empty => {
if query_trimmed.len() < 2 {
return score_filtered_by_frecency(&working_files, context);
}
&single_part_storage
}
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
_ => {
return score_filtered_by_frecency(&working_files, context);
}
},
};
let has_uppercase = fuzzy_parts
@@ -286,6 +281,7 @@ pub fn match_and_score_files<'a>(
let combo_match_boost = {
let last_same_query_match = context
.last_same_query_match
.as_ref()
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
match last_same_query_match {
@@ -327,6 +323,7 @@ pub fn match_and_score_files<'a>(
match_type: match filename_match {
Some(filename_match) if filename_match.exact => "exact_filename",
Some(_) => "fuzzy_filename",
None if path_match.exact => "exact_path",
None => "fuzzy_path",
},
};
@@ -501,6 +498,7 @@ fn sort_and_paginate<'a>(
mod tests {
use super::*;
use crate::types::PaginationArgs;
use fff_query_parser::QueryParser;
use std::path::PathBuf;
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
@@ -552,9 +550,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
@@ -601,9 +601,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
@@ -648,9 +650,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
+93 -48
View File
@@ -1,18 +1,41 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use memmap2::Mmap;
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
/// A single indexed file with metadata, frecency scores, and lazy mmap.
/// Cached file contents — mmap on Unix, heap buffer on Windows.
///
/// The `mmap` field holds the memory-mapped file contents, initialized lazily
/// on the first grep access and cached for subsequent searches. The mmap is
/// backed by the kernel page cache and automatically reflects file modifications
/// — no manual invalidation is needed.
/// On Windows, memory-mapped files hold the file handle open and prevent
/// editors from saving (writing/replacing) those files. Reading into a
/// `Vec<u8>` releases the handle immediately after the read completes.
#[derive(Debug)]
#[allow(dead_code)] // variants are conditionally used per platform
enum FileContent {
#[cfg(not(target_os = "windows"))]
Mmap(memmap2::Mmap),
#[cfg(target_os = "windows")]
Buffer(Vec<u8>),
}
impl std::ops::Deref for FileContent {
type Target = [u8];
fn deref(&self) -> &[u8] {
match self {
#[cfg(not(target_os = "windows"))]
FileContent::Mmap(m) => m,
#[cfg(target_os = "windows")]
FileContent::Buffer(b) => b,
}
}
}
/// A single indexed file with metadata, frecency scores, and lazy content cache.
///
/// File contents are initialized lazily on the first grep access and cached for
/// subsequent searches. On Unix, uses mmap backed by the kernel page cache. On
/// Windows, reads into a heap buffer to avoid holding file handles open.
///
/// Thread-safety: `OnceLock` provides lock-free reads after initialization.
/// Each file is only searched by one rayon worker at a time via `par_iter`.
@@ -30,10 +53,9 @@ pub struct FileItem {
pub total_frecency_score: i64,
pub git_status: Option<git2::Status>,
pub is_binary: bool,
/// Lazily-initialized memory-mapped file contents for grep.
/// Lazily-initialized file contents for grep.
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
/// Automatically reflects file changes via the kernel page cache.
mmap: OnceLock<Mmap>,
content: OnceLock<FileContent>,
}
impl Clone for FileItem {
@@ -51,8 +73,8 @@ impl Clone for FileItem {
total_frecency_score: self.total_frecency_score,
git_status: self.git_status,
is_binary: self.is_binary,
// Don't clone the mmap — the clone lazily re-creates it on demand
mmap: OnceLock::new(),
// Don't clone the content — the clone lazily re-creates it on demand
content: OnceLock::new(),
}
}
}
@@ -83,46 +105,65 @@ impl FileItem {
total_frecency_score: 0,
git_status,
is_binary,
mmap: OnceLock::new(),
content: OnceLock::new(),
}
}
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
/// Invalidate the cached content so the next `get_content()` call creates a fresh one.
///
/// Call this when the background watcher detects that the file has been modified.
/// While the kernel page cache reflects content changes automatically, a file
/// that is truncated (made smaller) while mapped can cause SIGBUS if the search
/// accesses pages beyond the new file size. Invalidating the mmap ensures a
/// fresh mapping with the correct size is created on the next access.
/// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
/// the stale buffer simply won't reflect the new contents. In both cases,
/// invalidating ensures a fresh read on the next access.
pub fn invalidate_mmap(&mut self) {
self.mmap = OnceLock::new();
self.content = OnceLock::new();
}
/// Get the cached mmap or lazily create it. Returns `None` if the file
/// is too large, empty, or can't be opened/mapped.
/// Get the cached file contents or lazily load them. Returns `None` if the
/// file is too large, empty, or can't be opened.
///
/// After the first call, this is lock-free (just an atomic load + pointer deref).
/// The mmap is backed by the kernel page cache and automatically reflects
/// file modifications — no manual invalidation is needed.
/// On Unix, uses mmap backed by the kernel page cache. On Windows, reads into
/// a heap buffer so the file handle is released immediately.
#[inline]
pub fn get_mmap(&self) -> Option<&Mmap> {
if let Some(mmap) = self.mmap.get() {
return Some(mmap);
pub fn get_content(&self) -> Option<&[u8]> {
if let Some(content) = self.content.get() {
return Some(content);
}
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
return None;
}
let file = std::fs::File::open(&self.path).ok()?;
let content = load_file_content(&self.path)?;
// If another thread raced us, OnceLock discards ours and returns theirs.
Some(self.content.get_or_init(|| content))
}
/// Backward-compatible alias for `get_content`.
#[inline]
pub fn get_mmap(&self) -> Option<&[u8]> {
self.get_content()
}
}
/// Load file contents: mmap on Unix, heap buffer on Windows.
fn load_file_content(path: &Path) -> Option<FileContent> {
#[cfg(not(target_os = "windows"))]
{
let file = std::fs::File::open(path).ok()?;
// SAFETY: The mmap is backed by the kernel page cache and automatically
// reflects file modifications. The only risk is SIGBUS if the file is
// truncated while mapped
let mmap = unsafe { Mmap::map(&file) }.ok()?;
// truncated while mapped.
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
Some(FileContent::Mmap(mmap))
}
// If another thread raced us, OnceLock discards our mmap and returns theirs.
// This is fine — the duplicate mmap is just dropped.
Some(self.mmap.get_or_init(|| mmap))
#[cfg(target_os = "windows")]
{
let data = std::fs::read(path).ok()?;
Some(FileContent::Buffer(data))
}
}
@@ -148,7 +189,7 @@ impl Constrainable for FileItem {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct Score {
pub total: i32,
pub base_score: i32,
@@ -169,38 +210,42 @@ pub struct PaginationArgs {
pub limit: usize,
}
impl Default for PaginationArgs {
fn default() -> Self {
Self {
offset: 0,
limit: 100,
}
}
}
/// Context for scoring files during search.
///
/// The `parsed_query` field contains the pre-parsed query with constraints,
/// The `query` field contains the pre-parsed query with constraints,
/// fuzzy parts, and location information. Parsing is done once at the API
/// boundary and passed through.
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
/// The original raw query string (for compatibility and debugging)
pub raw_query: &'a str,
/// Pre-parsed query containing constraints, fuzzy parts, and location
pub parsed_query: Option<FFFQuery<'a>>,
/// Parsed query containing raw text, constraints, fuzzy parts, and location
pub query: &'a FFFQuery<'a>,
pub project_path: Option<&'a Path>,
pub current_file: Option<&'a str>,
pub max_typos: u16,
pub max_threads: usize,
pub last_same_query_match: Option<&'a QueryMatchEntry>,
pub last_same_query_match: Option<QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
}
impl<'a> ScoringContext<'a> {
impl ScoringContext<'_> {
/// Get the effective fuzzy query string for matching.
/// Returns the first fuzzy part, or the raw query if no parsing was done.
pub fn effective_query(&self) -> &'a str {
match &self.parsed_query {
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.raw_query.trim(),
},
None => self.raw_query.trim(),
pub fn effective_query(&self) -> &str {
match &self.query.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.query.raw_query.trim(),
}
}
}
+125 -68
View File
@@ -2,8 +2,8 @@ use std::fs;
use std::path::Path;
use tempfile::TempDir;
use fff_core::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use fff_core::types::FileItem;
use fff_search::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use fff_search::types::FileItem;
/// Create a file inside a temp dir and return its `FileItem`.
fn create_file(base: &Path, relative: &str, contents: &str) -> FileItem {
@@ -72,7 +72,8 @@ fn plain_text_finds_exact_literal() {
"Hello, World!\nGoodbye, World!\n",
)];
let result = grep_search(&files, "Hello", None, &plain_opts());
let parsed = parse_grep_query("Hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -89,7 +90,8 @@ fn plain_text_smart_case_insensitive() {
)];
// All lowercase query → smart case → case-insensitive
let result = grep_search(&files, "hello", None, &plain_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -108,7 +110,8 @@ fn plain_text_smart_case_sensitive_with_uppercase() {
)];
// Query has uppercase → smart case → case-sensitive
let result = grep_search(&files, "Hello", None, &plain_opts());
let parsed = parse_grep_query("Hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -128,13 +131,15 @@ fn plain_text_regex_metacharacters_are_literal() {
)];
// In plain text mode, these regex metacharacters should be literal
let result = grep_search(&files, "fn main()", None, &plain_opts());
let parsed = parse_grep_query("fn main()");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
// Parentheses should NOT be treated as regex groups
let result2 = grep_search(&files, "(\"test\")", None, &plain_opts());
let parsed2 = parse_grep_query("(\"test\")");
let result2 = grep_search(&files, &parsed2, &plain_opts());
assert_eq!(result2.matches.len(), 1);
assert_eq!(result2.matches[0].line_number, 2);
}
@@ -149,7 +154,8 @@ fn plain_text_dot_is_literal() {
)];
// In plain text mode, dot should be literal, not "any char"
let result = grep_search(&files, "1.0", None, &plain_opts());
let parsed = parse_grep_query("1.0");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -168,7 +174,8 @@ fn plain_text_asterisk_is_literal() {
"Use **bold** text\nUse *italic* text\nUse normal text\n",
)];
let result = grep_search(&files, "**bold**", None, &plain_opts());
let parsed = parse_grep_query("**bold**");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
}
@@ -182,7 +189,8 @@ fn plain_text_backslash_is_literal() {
"C:\\Users\\foo\\bar\n/home/user/bin\n",
)];
let result = grep_search(&files, "C:\\Users", None, &plain_opts());
let parsed = parse_grep_query("C:\\Users");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
}
@@ -195,7 +203,8 @@ fn plain_text_across_multiple_files() {
create_file(tmp.path(), "c.txt", "no match here\n"),
];
let result = grep_search(&files, "use std", None, &plain_opts());
let parsed = parse_grep_query("use std");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 3);
// Should match in files a.txt and b.txt
@@ -207,7 +216,8 @@ fn plain_text_highlight_offsets_are_correct() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "foo bar foo baz foo\n")];
let result = grep_search(&files, "foo", None, &plain_opts());
let parsed = parse_grep_query("foo");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -224,7 +234,8 @@ fn plain_text_empty_query_returns_no_content_matches() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "some content\n")];
let result = grep_search(&files, "", None, &plain_opts());
let parsed = parse_grep_query("");
let result = grep_search(&files, &parsed, &plain_opts());
// Empty query in grep returns git-modified welcome state (no content matches)
// Since our test files have no git status, we expect 0 matches
@@ -245,7 +256,8 @@ fn plain_text_binary_files_are_skipped() {
let files = vec![binary_file, text_file];
let result = grep_search(&files, "match this text", None, &plain_opts());
let parsed = parse_grep_query("match this text");
let result = grep_search(&files, &parsed, &plain_opts());
// Only the text file should be searched, not the binary one
assert_eq!(result.files.len(), 1);
@@ -264,7 +276,8 @@ fn plain_text_max_matches_per_file() {
let mut opts = plain_opts();
opts.max_matches_per_file = 5;
let result = grep_search(&files, "match_target", None, &opts);
let parsed = parse_grep_query("match_target");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(
result.matches.len(),
@@ -285,7 +298,8 @@ fn plain_text_page_limit() {
let mut opts = plain_opts();
opts.page_limit = 10;
let result = grep_search(&files, "target", None, &opts);
let parsed = parse_grep_query("target");
let result = grep_search(&files, &parsed, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
@@ -327,7 +341,8 @@ fn plain_text_file_offset_pagination() {
let max_pages = 20; // safety limit
loop {
let result = grep_search(&files, "unique_token", None, &opts);
let parsed = parse_grep_query("unique_token");
let result = grep_search(&files, &parsed, &opts);
for m in &result.matches {
let text = m.line_content.trim().to_string();
@@ -377,7 +392,8 @@ fn plain_text_line_numbers_are_correct() {
"line one\nline two\nline three\nline four\n",
)];
let result = grep_search(&files, "line", None, &plain_opts());
let parsed = parse_grep_query("line");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 4);
assert_eq!(result.matches[0].line_number, 1);
@@ -396,7 +412,8 @@ fn plain_text_max_file_size_filter() {
let mut opts = plain_opts();
opts.max_file_size = 100; // Only allow files up to 100 bytes
let result = grep_search(&files, "match_me", None, &opts);
let parsed = parse_grep_query("match_me");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(result.matches.len(), 0, "large file should be filtered out");
assert_eq!(result.filtered_file_count, 0);
@@ -413,7 +430,8 @@ fn regex_basic_pattern() {
"foo123\nbar456\nbaz789\nfoo_bar\n",
)];
let result = grep_search(&files, "foo\\d+", None, &regex_opts());
let parsed = parse_grep_query("foo\\d+");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -426,7 +444,8 @@ fn regex_capture_group_matching() {
let files = vec![create_file(tmp.path(), "a.txt", "foobar\nfoobaz\nfoo123\n")];
// Use a capturing group (not lookahead, which regex crate doesn't support)
let result = grep_search(&files, "foo(bar|baz)", None, &regex_opts());
let parsed = parse_grep_query("foo(bar|baz)");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 2);
let contents: Vec<&str> = result
@@ -448,7 +467,8 @@ fn regex_dot_matches_any_char() {
)];
// In regex mode, . matches any character, so v1.0 matches v1.0, v1x0, v1-0, and v100
let result = grep_search(&files, "v1.0", None, &regex_opts());
let parsed = parse_grep_query("v1.0");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -466,7 +486,8 @@ fn regex_alternation() {
"apple\nbanana\ncherry\napricot\n",
)];
let result = grep_search(&files, "apple|cherry", None, &regex_opts());
let parsed = parse_grep_query("apple|cherry");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 2);
let lines: Vec<u64> = result.matches.iter().map(|m| m.line_number).collect();
@@ -483,7 +504,8 @@ fn regex_character_class() {
"cat\ncut\ncot\ncit\ncxt\n",
)];
let result = grep_search(&files, "c[aou]t", None, &regex_opts());
let parsed = parse_grep_query("c[aou]t");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 3);
let contents: Vec<&str> = result
@@ -505,7 +527,8 @@ fn regex_quantifiers() {
"fo\nfoo\nfooo\nfoooo\nbar\n",
)];
let result = grep_search(&files, "fo{2,3}", None, &regex_opts());
let parsed = parse_grep_query("fo{2,3}");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 3, "should match foo, fooo, foooo");
}
@@ -519,7 +542,8 @@ fn regex_anchors() {
"start of line\nmiddle start end\nend of line\n",
)];
let result = grep_search(&files, "^start", None, &regex_opts());
let parsed = parse_grep_query("^start");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -535,7 +559,8 @@ fn regex_anchors_multiword() {
)];
// ^int ff_ should match lines starting with "int ff_"
let result = grep_search(&files, "^int ff_", None, &regex_opts());
let parsed = parse_grep_query("^int ff_");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -551,7 +576,8 @@ fn regex_highlight_offsets_variable_length() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "aab aaab aaaab\n")];
let result = grep_search(&files, "a+b", None, &regex_opts());
let parsed = parse_grep_query("a+b");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -573,7 +599,8 @@ fn regex_invalid_pattern_falls_back_to_literal() {
)];
// Invalid regex: unmatched group — should fall back to literal search
let result = grep_search(&files, "name(", None, &regex_opts());
let parsed = parse_grep_query("name(");
let result = grep_search(&files, &parsed, &regex_opts());
// Fallback to literal: finds "name(" in "call name(arg)"
assert_eq!(
@@ -588,7 +615,8 @@ fn regex_invalid_pattern_falls_back_to_literal() {
assert!(result.matches[0].line_content.contains("name("));
// A pattern that doesn't exist anywhere — still falls back but finds nothing
let result2 = grep_search(&files, "zzz(", None, &regex_opts());
let parsed2 = parse_grep_query("zzz(");
let result2 = grep_search(&files, &parsed2, &regex_opts());
assert_eq!(result2.matches.len(), 0);
assert!(result2.regex_fallback_error.is_some());
}
@@ -603,11 +631,13 @@ fn regex_smart_case() {
)];
// Lowercase query → case-insensitive
let result_lower = grep_search(&files, "foo", None, &regex_opts());
let parsed_lower = parse_grep_query("foo");
let result_lower = grep_search(&files, &parsed_lower, &regex_opts());
assert_eq!(result_lower.matches.len(), 3);
// Query with uppercase → case-sensitive
let result_upper = grep_search(&files, "Foo", None, &regex_opts());
let parsed_upper = parse_grep_query("Foo");
let result_upper = grep_search(&files, &parsed_upper, &regex_opts());
assert_eq!(result_upper.matches.len(), 1);
}
@@ -628,7 +658,8 @@ fn regex_across_multiple_files() {
create_file(tmp.path(), "readme.md", "# Title\nSome text\n"),
];
let result = grep_search(&files, "fn \\w+\\(\\)", None, &regex_opts());
let parsed = parse_grep_query("fn \\w+\\(\\)");
let result = grep_search(&files, &parsed, &regex_opts());
// Should match: fn main(), fn helper(), fn test_one(), fn test_two()
assert_eq!(result.matches.len(), 4);
@@ -646,8 +677,9 @@ fn plain_text_and_regex_agree_on_simple_literal() {
"hello world\ngoodbye world\nhello again\n",
)];
let plain_result = grep_search(&files, "hello", None, &plain_opts());
let regex_result = grep_search(&files, "hello", None, &regex_opts());
let parsed = parse_grep_query("hello");
let plain_result = grep_search(&files, &parsed, &plain_opts());
let regex_result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(plain_result.matches.len(), regex_result.matches.len());
for (p, r) in plain_result.matches.iter().zip(regex_result.matches.iter()) {
@@ -666,8 +698,10 @@ fn plain_text_escapes_what_regex_does_not() {
)];
// "$100" — in plain text, $ is literal; in regex, $ is anchor
let plain_result = grep_search(&files, "$100", None, &plain_opts());
let regex_result = grep_search(&files, "\\$100", None, &regex_opts());
let parsed_plain = parse_grep_query("$100");
let plain_result = grep_search(&files, &parsed_plain, &plain_opts());
let parsed_regex = parse_grep_query("\\$100");
let regex_result = grep_search(&files, &parsed_regex, &regex_opts());
// Plain text should find "$100" literally
assert_eq!(plain_result.matches.len(), 1);
@@ -689,7 +723,7 @@ fn grep_with_extension_constraint() {
];
let parsed = parse_grep_query("use std *.rs");
let result = grep_search(&files, "use std *.rs", parsed.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
// Should only search .rs files
for file in &result.files {
@@ -716,7 +750,8 @@ fn plain_text_bracket_is_literal() {
"let x = arr[0];\nlet y = arr[1];\nlet z = something;\n",
)];
let result = grep_search(&files, "arr[0]", None, &plain_opts());
let parsed = parse_grep_query("arr[0]");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -738,7 +773,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.as_ref(), &plain_opts());
let result_filter = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result_filter.files.len(),
1,
@@ -747,7 +782,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.as_ref(), &plain_opts());
let result_literal = grep_search(&files, &parsed_escaped, &plain_opts());
assert_eq!(
result_literal.matches.len(),
2,
@@ -764,7 +799,8 @@ fn grep_backslash_escapes_path_segment() {
];
// With escape: "\\/src/" is literal text, not a path constraint
let result = grep_search(&files, "\\/src/", None, &plain_opts());
let parsed = parse_grep_query("\\/src/");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
2,
@@ -782,7 +818,8 @@ fn grep_backslash_escapes_negation() {
)];
// With escape: "\\!test" is literal text "!test"
let result = grep_search(&files, "\\!test", None, &plain_opts());
let parsed = parse_grep_query("\\!test");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert!(result.matches[0].line_content.contains("!test"));
}
@@ -797,7 +834,7 @@ fn grep_with_path_constraint() {
];
let parsed = parse_grep_query("target_text /src/");
let result = grep_search(&files, "target_text /src/", parsed.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert!(result.files[0].relative_path.starts_with("src/"));
@@ -816,7 +853,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.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -842,7 +879,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.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -868,7 +905,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.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
// "tests/helper.rs" contains "test" in path, should be excluded
assert_eq!(
@@ -898,7 +935,8 @@ fn grep_empty_file_is_skipped() {
let text_file = create_file(tmp.path(), "text.txt", "findme\n");
let files = vec![empty_file, text_file];
let result = grep_search(&files, "findme", None, &plain_opts());
let parsed = parse_grep_query("findme");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
}
@@ -908,7 +946,8 @@ fn grep_single_line_no_trailing_newline() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "no newline at end")];
let result = grep_search(&files, "no newline", None, &plain_opts());
let parsed = parse_grep_query("no newline");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -923,11 +962,13 @@ fn grep_unicode_content() {
"日本語テスト\nrégulière\nñoño\n",
)];
let result = grep_search(&files, "régulière", None, &plain_opts());
let parsed = parse_grep_query("régulière");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 2);
let result2 = grep_search(&files, "ñoño", None, &plain_opts());
let parsed2 = parse_grep_query("ñoño");
let result2 = grep_search(&files, &parsed2, &plain_opts());
assert_eq!(result2.matches.len(), 1);
assert_eq!(result2.matches[0].line_number, 3);
}
@@ -938,7 +979,8 @@ fn grep_long_line_is_truncated() {
let long_line = "x".repeat(1000) + "NEEDLE" + &"y".repeat(1000);
let files = vec![create_file(tmp.path(), "long.txt", &long_line)];
let result = grep_search(&files, "NEEDLE", None, &plain_opts());
let parsed = parse_grep_query("NEEDLE");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
// The line_content should be truncated to MAX_LINE_DISPLAY_LEN (512)
@@ -958,7 +1000,8 @@ fn regex_word_boundary() {
"foo\nfoobar\nbarfoo\nfoo_baz\n",
)];
let result = grep_search(&files, "\\bfoo\\b", None, &regex_opts());
let parsed = parse_grep_query("\\bfoo\\b");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -977,7 +1020,8 @@ fn plain_text_question_mark_is_literal() {
"what is this?\nhow does it work?\nno question here\nwhat?\n",
)];
let result = grep_search(&files, "?", None, &plain_opts());
let parsed = parse_grep_query("?");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -995,7 +1039,8 @@ fn plain_text_query_with_question_mark_in_word() {
"let x = foo?;\nlet y = bar.baz();\nfoo?.unwrap()\n",
)];
let result = grep_search(&files, "foo?", None, &plain_opts());
let parsed = parse_grep_query("foo?");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -1010,7 +1055,8 @@ fn regex_question_mark_is_quantifier() {
let files = vec![create_file(tmp.path(), "a.txt", "color\ncolour\ncolouur\n")];
// In regex mode, ? means "zero or one of preceding"
let result = grep_search(&files, "colou?r", None, &regex_opts());
let parsed = parse_grep_query("colou?r");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -1030,7 +1076,8 @@ fn fuzzy_finds_exact_substring() {
"hello world\ngoodbye world\nhello again\n",
)];
let result = grep_search(&files, "hello", None, &fuzzy_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(
result.matches.len(),
@@ -1051,7 +1098,8 @@ fn fuzzy_finds_scattered_characters() {
)];
// "mutex" should fuzzy match "mutex_lock" (contiguous prefix)
let result = grep_search(&files, "mutex", None, &fuzzy_opts());
let parsed = parse_grep_query("mutex");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert!(
!result.matches.is_empty(),
@@ -1065,7 +1113,8 @@ fn fuzzy_highlight_offsets_correct() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "hello world\n")];
let result = grep_search(&files, "hell", None, &fuzzy_opts());
let parsed = parse_grep_query("hell");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -1089,7 +1138,8 @@ fn fuzzy_unicode_char_indices() {
// Use "guli" which is a contiguous ASCII substring within "régulière"
// (the chars g-u-l-i appear contiguously between the two accented chars)
let result = grep_search(&files, "guli", None, &fuzzy_opts());
let parsed = parse_grep_query("guli");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should fuzzy match "régulière" (with multi-byte é and è)
// This tests that character-to-byte offset conversion works with UTF-8
@@ -1102,7 +1152,8 @@ fn fuzzy_empty_query_returns_empty() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "some content\n")];
let result = grep_search(&files, "", None, &fuzzy_opts());
let parsed = parse_grep_query("");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Empty query returns git-modified files, not fuzzy matches
assert_eq!(result.matches.len(), 0);
@@ -1118,7 +1169,7 @@ fn fuzzy_with_extension_constraint() {
];
let parsed = parse_grep_query("use std *.rs");
let result = grep_search(&files, "use std *.rs", parsed.as_ref(), &fuzzy_opts());
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should only search .rs files
for file in &result.files {
@@ -1143,7 +1194,8 @@ fn fuzzy_respects_page_limit() {
opts.page_limit = 10;
opts.max_matches_per_file = 50;
let result = grep_search(&files, "target", None, &opts);
let parsed = parse_grep_query("target");
let result = grep_search(&files, &parsed, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
@@ -1178,7 +1230,8 @@ fn fuzzy_respects_max_matches_per_file() {
let mut opts = fuzzy_opts();
opts.max_matches_per_file = 5;
let result = grep_search(&files, "match", None, &opts);
let parsed = parse_grep_query("match");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(
result.matches.len(),
@@ -1198,7 +1251,8 @@ fn fuzzy_filters_low_quality_matches() {
// Search for "abc" - should match "abc_def_ghi" and "abcdefghij" with high scores,
// but NOT "xyz" (no relation) or "mutex_lock" (only weak letter overlap)
let result = grep_search(&files, "abc", None, &fuzzy_opts());
let parsed = parse_grep_query("abc");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should only get high-quality matches
assert!(
@@ -1227,7 +1281,8 @@ fn fuzzy_exact_match_always_passes() {
)];
// Exact matches should always pass regardless of score threshold
let result = grep_search(&files, "exact", None, &fuzzy_opts());
let parsed = parse_grep_query("exact");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(
result.matches.len(),
@@ -1246,7 +1301,8 @@ fn fuzzy_score_is_captured() {
"hello world\ngoodbye world\n",
)];
let result = grep_search(&files, "hello", None, &fuzzy_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -1267,7 +1323,8 @@ fn fuzzy_score_is_none_in_plain_mode() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "test.txt", "hello world\n")];
let result = grep_search(&files, "hello", None, &plain_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -1,6 +1,9 @@
[package]
name = "grep-searcher"
version = "0.1.16"
name = "fff-grep"
description = "File grepping logic for fff"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
version = "0.2.4"
edition = "2024"
[dependencies]
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-mcp"
version = "0.1.0"
version = "0.2.4"
edition = "2024"
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
license = "MIT"
@@ -11,11 +11,11 @@ path = "src/main.rs"
[features]
default = ["zlob"]
zlob = ["fff-core/zlob"]
zlob = ["fff/zlob"]
[dependencies]
fff-core = { path = "../fff-core", default-features = false }
fff-query-parser = { path = "../fff-query-parser", default-features = false }
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.2.2" }
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.2.4" }
mimalloc = { workspace = true }
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
schemars = "1.0"
+17 -13
View File
@@ -14,9 +14,9 @@ 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 fff::file_picker::FilePicker;
use fff::frecency::FrecencyTracker;
use fff::{FFFMode, SharedFrecency, SharedPicker};
use git2::Repository;
use mimalloc::MiMalloc;
use rmcp::{ServiceExt, transport::stdio};
@@ -71,13 +71,15 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
"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",
" Extension: '*.rs', '*.{ts,tsx}'\n",
" Directory: 'src/', 'quotes/'\n",
" Filename: 'schema.rs', 'src/main.rs'\n",
" Exclude: '!test/', '!*.spec.ts'\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",
"! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n",
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
"\n",
"Prefer broad constraints:\n",
" + '*.rs query' -> file type\n",
@@ -101,7 +103,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
/// FFF MCP Server — high-performance file finder for AI code assistants.
#[derive(Parser)]
#[command(name = "fff-mcp", version = env!("CARGO_PKG_VERSION"))]
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
struct Args {
/// Base directory to index. Defaults to the current working directory.
#[arg(value_name = "PATH")]
@@ -196,7 +198,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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()) {
if let Err(e) = fff::log::init_tracing(log_file, args.log_level.as_deref()) {
eprintln!("Warning: Failed to init tracing: {}", e);
}
@@ -207,7 +209,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.to_string()
});
if !Repository::discover(&base_path).is_ok() {
if Repository::discover(&base_path).is_err() {
tracing::error!("MCP server must be run within a Git repository");
return Err(format!("Not a Git repository: {}", base_path).into());
}
@@ -221,7 +223,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(mut guard) = shared_frecency.write() {
*guard = Some(tracker);
}
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
let _ =
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
}
Err(e) => {
eprintln!("Warning: Failed to init frecency db: {}", e);
+4 -4
View File
@@ -3,10 +3,10 @@
//! 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 fff::GrepMatch;
use fff::git::format_git_status_opt;
use fff::grep::is_import_line;
use fff::types::FileItem;
use crate::cursor::CursorStore;
+53 -62
View File
@@ -8,10 +8,10 @@ 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::file_picker::FilePicker;
use fff::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff::types::{FileItem, PaginationArgs};
use fff::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
@@ -21,12 +21,12 @@ 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 {
/// Strip common delimiters and lowercase for fuzzy fallback queries.
fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if !matches!(c, ':' | '-' | '_') {
out.push(c);
out.extend(c.to_lowercase());
}
}
out
@@ -253,7 +253,7 @@ impl FffServer {
let parser = QueryParser::new(AiGrepConfig);
let parsed = parser.parse(query);
let result = grep::grep_search(files, query, parsed.as_ref(), &options);
let result = grep::grep_search(files, &parsed, &options);
if result.matches.is_empty() && file_offset == 0 {
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
@@ -268,10 +268,7 @@ impl FffServer {
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 rest_text = rest_parsed.grep_text();
let retry_mode = if has_regex_metacharacters(&rest_text) {
GrepMode::Regex
} else {
@@ -279,8 +276,7 @@ impl FffServer {
};
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);
let retry_result = grep::grep_search(files, &rest_parsed, &retry_options);
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
let mut cs = self.lock_cursors()?;
@@ -305,11 +301,10 @@ impl FffServer {
}
// Fuzzy fallback for typo tolerance
let fuzzy_query = strip_delimiters(&query.to_lowercase());
let fuzzy_query = cleanup_fuzzy_query(query);
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
let fuzzy_parsed = parser.parse(&fuzzy_query);
let fuzzy_result =
grep::grep_search(files, &fuzzy_query, fuzzy_parsed.as_ref(), &fuzzy_options);
let fuzzy_result = grep::grep_search(files, &fuzzy_parsed, &fuzzy_options);
if !fuzzy_result.matches.is_empty() {
let mut lines: Vec<String> = Vec::new();
@@ -331,35 +326,39 @@ impl FffServer {
)]));
}
let hint = match &parsed {
Some(q)
if q.constraints
.iter()
.any(|c| matches!(c, Constraint::FilePath(_))) =>
// File path fallback: if query looks like a path, suggest the matching file
if query.contains('/') {
let file_parser = QueryParser::default();
let file_query = file_parser.parse(query);
let file_opts = FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(picker.base_path()),
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 1,
},
};
let file_result = FilePicker::fuzzy_search(files, &file_query, None, file_opts);
if let (Some(top), Some(score)) =
(file_result.items.first(), file_result.scores.first())
{
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."
)
// Only suggest when the match is strong enough.
let query_len = query.len() as i32;
if score.base_score > query_len * 10 {
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 content matches. But there is a relevant file path: {}",
top.relative_path
))]));
}
}
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
))]));
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
if result.matches.is_empty() {
@@ -423,7 +422,6 @@ impl FffServer {
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 {
@@ -434,7 +432,7 @@ impl FffServer {
let parser = QueryParser::default();
let fff_query = parser.parse(query);
let result = FilePicker::fuzzy_search(files, query, fff_query, make_opts(page_offset));
let result = FilePicker::fuzzy_search(files, &fff_query, None, make_opts(page_offset));
let total_files = result.total_files;
// Auto-retry with fewer terms if 3+ words return 0 results
@@ -445,8 +443,12 @@ impl FffServer {
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));
let retry = FilePicker::fuzzy_search(
files,
&shorter_query,
/*query_tracker=*/ None,
make_opts(0),
);
(retry.items, retry.scores, retry.total_matched)
} else {
@@ -518,10 +520,7 @@ impl FffServer {
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 grep_text = parsed.grep_text();
let mode = if has_regex_metacharacters(&grep_text) {
GrepMode::Regex
@@ -587,15 +586,8 @@ impl FffServer {
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 parsed_constraints = parser.parse(constraint_query);
let constraints = parsed_constraints.constraints.as_slice();
let files = picker.get_files();
let result = grep::multi_grep_search(files, &patterns_refs, constraints, &options);
@@ -620,8 +612,7 @@ impl FffServer {
};
let parsed = parser.parse(&full_query);
let fb_result =
grep::grep_search(files, &full_query, parsed.as_ref(), &fallback_options);
let fb_result = grep::grep_search(files, &parsed, &fallback_options);
if !fb_result.matches.is_empty() {
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
-40
View File
@@ -76,43 +76,3 @@ fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
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"
);
}
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-nvim"
version = "0.1.0"
version = "0.2.4"
edition = "2024"
[lib]
@@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib"]
[features]
default = []
zlob = ["fff-core/zlob"]
zlob = ["fff/zlob"]
[[bin]]
name = "test_watcher"
@@ -44,8 +44,8 @@ thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff-core = { path = "../fff-core" }
fff-query-parser = { path = "../fff-query-parser" }
fff = { package = "fff-search", path = "../fff-core" , version = "0.2.2" }
fff-query-parser = { path = "../fff-query-parser" , version = "0.2.4" }
# External dependencies
blake3 = "1.8.2"
+33 -46
View File
@@ -1,7 +1,7 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::types::{FileItem, PaginationArgs};
use fff::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
@@ -129,7 +129,7 @@ fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String>
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
}
let canonical_path = fff_core::path_utils::canonicalize(&big_repo_path)
let canonical_path = fff::path_utils::canonicalize(&big_repo_path)
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
@@ -165,7 +165,7 @@ fn bench_indexing(c: &mut Criterion) {
return;
}
let canonical_path = match fff_core::path_utils::canonicalize(&big_repo_path) {
let canonical_path = match fff::path_utils::canonicalize(&big_repo_path) {
Ok(p) => p,
Err(e) => {
eprintln!("⚠ Failed to canonicalize path: {}", e);
@@ -226,17 +226,18 @@ fn bench_search_queries(c: &mut Criterion) {
("partial", "src/lib"),
];
let parser = QueryParser::default();
for (name, query) in test_queries {
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &query| {
let parsed = parser.parse(query);
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &_query| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -269,6 +270,8 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let thread_counts = vec![1, 2, 4, 8];
for threads in thread_counts {
@@ -279,12 +282,10 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -318,6 +319,8 @@ fn bench_search_result_limits(c: &mut Criterion) {
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result_limits = vec![10, 50, 100, 500];
for limit in result_limits {
@@ -325,12 +328,10 @@ fn bench_search_result_limits(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -371,6 +372,8 @@ fn bench_search_scalability(c: &mut Criterion) {
group.sample_size(50);
let query = "controller";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
for count in file_counts {
@@ -383,12 +386,10 @@ fn bench_search_scalability(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -420,19 +421,19 @@ fn bench_search_ordering(c: &mut Criterion) {
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed_controller = parser.parse("controller");
let parsed_mod = parser.parse("mod");
// Benchmark normal order (descending)
group.bench_function("normal_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -452,12 +453,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -477,12 +476,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
None,
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -501,12 +498,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
None,
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -526,12 +521,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -550,12 +543,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -587,6 +578,8 @@ fn bench_pagination_performance(c: &mut Criterion) {
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let page_size = 40;
// Benchmark first page (uses partial sort optimization)
@@ -594,12 +587,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -619,12 +610,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -644,12 +633,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -1,5 +1,5 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_core::query_tracker::QueryTracker;
use fff::query_tracker::QueryTracker;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use std::path::PathBuf;
+5 -6
View File
@@ -1,6 +1,6 @@
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
use fff_core::file_picker::FilePicker;
use fff_core::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use fff::file_picker::FilePicker;
use fff::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
use std::time::Instant;
fn main() {
@@ -14,7 +14,7 @@ fn main() {
}
let canonical_path =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
eprintln!("Loading files from: {:?}", canonical_path);
@@ -87,13 +87,12 @@ fn main() {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
&parsed,
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+4 -5
View File
@@ -1,4 +1,4 @@
use fff_core::FileItem;
use fff::FileItem;
/// Fuzzy grep quality test against ~/dev/lightsource
///
/// Runs queries through the fuzzy grep pipeline and prints results
@@ -7,7 +7,7 @@ use fff_core::FileItem;
/// Usage:
/// cargo run --release --bin fuzzy_grep_test # runs default test queries
/// cargo run --release --bin fuzzy_grep_test -- "query" # runs a single user query
use fff_core::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::time::Instant;
@@ -78,7 +78,7 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
eprintln!("══════════════════════════════════════════════════════════════");
@@ -166,8 +166,7 @@ fn main() {
std::process::exit(1);
}
let canonical =
fff_core::path_utils::canonicalize(&repo_path).expect("Failed to canonicalize path");
let canonical = fff::path_utils::canonicalize(&repo_path).expect("Failed to canonicalize path");
eprintln!("=== Fuzzy Grep Quality Test ===");
eprintln!("Repository: {:?}\n", canonical);
+5 -5
View File
@@ -1,4 +1,4 @@
use fff_core::FileItem;
use fff::FileItem;
/// Live grep benchmark profiler for fff.nvim
///
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
@@ -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::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::time::{Duration, Instant};
@@ -144,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.as_ref(), &self.options);
let result = grep_search(self.files, &parsed, &self.options);
let elapsed = start.elapsed();
(elapsed, result.matches.len(), result.total_files_searched)
}
@@ -222,7 +222,7 @@ fn main() {
std::process::exit(1);
}
let canonical = fff_core::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
eprintln!("=== FFF Live Grep Profiler ===");
eprintln!("Repository: {:?}", canonical);
@@ -452,7 +452,7 @@ fn main() {
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(&files, pagination_query, parsed.as_ref(), &opts);
let result = grep_search(&files, &parsed, &opts);
let elapsed = start.elapsed();
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
+8 -12
View File
@@ -1,5 +1,5 @@
use fff_core::FFFQuery;
use fff_core::FileItem;
use fff::FFFQuery;
use fff::FileItem;
/// FFF vs ripgrep comparison benchmark
///
/// Demonstrates why a persistent in-process search engine (fff) is fundamentally
@@ -21,7 +21,7 @@ use fff_core::FileItem;
/// Usage:
/// cargo build --release --bin grep_vs_rg
/// ./target/release/grep_vs_rg [--path /path/to/repo] [--iters 5]
use fff_core::grep::{GrepSearchOptions, grep_search, parse_grep_query};
use fff::grep::{GrepSearchOptions, grep_search, parse_grep_query};
use std::io::Read;
use std::path::Path;
use std::process::Command;
@@ -210,17 +210,13 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, &parsed, &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) {
fn benchmark_fff_smart_case(files: &[FileItem], parsed: &FFFQuery<'_>) -> (usize, Duration) {
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: usize::MAX,
@@ -234,7 +230,7 @@ fn benchmark_fff_smart_case(
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
@@ -255,7 +251,7 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
@@ -323,7 +319,7 @@ fn main() {
std::process::exit(1);
}
let canonical = fff_core::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
let rg_version = Command::new("rg")
.arg("--version")
+4 -5
View File
@@ -1,5 +1,5 @@
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::sync::{Arc, RwLock};
use std::thread;
@@ -90,13 +90,12 @@ fn test_search_memory_pattern(
let parsed = parser.parse(&query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
&parsed,
None,
FuzzySearchOptions {
max_threads: 1 + (i % 4),
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+5 -6
View File
@@ -1,5 +1,5 @@
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{
use fff::file_picker::{FFFMode, FilePicker};
use fff::{
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
};
use std::sync::{Arc, RwLock};
@@ -71,7 +71,7 @@ fn main() {
}
let canonical_path =
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
// Create shared state
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
@@ -126,13 +126,12 @@ fn main() {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
&parsed,
None,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+4 -5
View File
@@ -1,5 +1,5 @@
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use fff::file_picker::{FFFMode, FilePicker};
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::sync::{Arc, RwLock};
@@ -200,13 +200,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let parsed = parser.parse(query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
query,
parsed,
&parsed,
None,
FuzzySearchOptions {
max_threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+5 -8
View File
@@ -2,11 +2,9 @@
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
use fff_core::file_picker::FilePicker;
use fff_core::git::format_git_status;
use fff_core::{
FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
};
use fff::file_picker::FilePicker;
use fff::git::format_git_status;
use fff::{FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
use std::env;
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -155,13 +153,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let parsed = parser.parse("rs");
let search_results = FilePicker::fuzzy_search(
files,
"rs",
parsed,
&parsed,
None,
FuzzySearchOptions {
max_threads: 2,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
+4 -4
View File
@@ -1,10 +1,10 @@
//! Error handling for fff-nvim
//!
//! This module provides utilities for converting fff_core errors to mlua errors.
//! This module provides utilities for converting fff errors to mlua errors.
use fff_core::Error as CoreError;
use fff::Error as CoreError;
/// Convert a fff_core::Error to mlua::Error
/// Convert a fff::Error to mlua::Error
///
/// This function is used because we can't implement From<CoreError> for mlua::Error
/// due to Rust's orphan rules (both types are foreign to this crate).
@@ -14,7 +14,7 @@ pub fn to_lua_error(err: CoreError) -> mlua::Error {
mlua::Error::RuntimeError(string_value)
}
/// Extension trait for Result<T, fff_core::Error> to convert to LuaResult<T>
/// Extension trait for Result<T, fff::Error> to convert to LuaResult<T>
pub trait IntoLuaResult<T> {
fn into_lua_result(self) -> mlua::Result<T>;
}
+190
View File
@@ -0,0 +1,190 @@
use mlua::prelude::*;
use std::fmt::Write as _;
use std::io::{Read, Seek, SeekFrom};
// Byte category colors (matching hexyl's default theme)
const COLOR_OFFSET: &str = "#888888";
const COLOR_NULL: &str = "#555753";
const COLOR_ASCII_PRINTABLE: &str = "#06989a";
const COLOR_ASCII_WHITESPACE: &str = "#4e9a06";
const COLOR_ASCII_OTHER: &str = "#4e9a06";
const COLOR_NON_ASCII: &str = "#c4a000";
fn byte_color(b: u8) -> &'static str {
match b {
0x00 => COLOR_NULL,
0x20 | 0x09 | 0x0a | 0x0d => COLOR_ASCII_WHITESPACE,
0x21..=0x7e => COLOR_ASCII_PRINTABLE,
0x01..=0x1f | 0x7f => COLOR_ASCII_OTHER,
_ => COLOR_NON_ASCII,
}
}
fn byte_char(b: u8) -> char {
match b {
0x20..=0x7e => b as char,
_ => '.',
}
}
const BYTES_PER_LINE: usize = 16;
struct Span {
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
}
/// Push a span, merging with the previous one if same line and color.
fn push_span(
spans: &mut Vec<Span>,
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
) {
if let Some(last) = spans.last_mut() {
// Merge if same line, same color, and adjacent (allow small gaps for spaces between hex pairs)
if last.line == line && std::ptr::eq(last.color, color) && col_start <= last.col_end + 1 {
last.col_end = col_end;
return;
}
}
spans.push(Span {
line,
col_start,
col_end,
color,
});
}
/// Format raw bytes into hex dump lines with coalesced highlight spans.
///
/// Layout per line:
/// ```text
/// XXXXXXXX HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH CCCCCCCCCCCCCCCC
/// ```
fn format_hex_dump(raw_bytes: &[u8], base_offset: u64) -> (Vec<String>, Vec<Span>) {
let mut lines = Vec::new();
let mut spans = Vec::new();
for (chunk_idx, chunk) in raw_bytes.chunks(BYTES_PER_LINE).enumerate() {
let addr = base_offset + (chunk_idx * BYTES_PER_LINE) as u64;
let mut line = format!("{addr:08x} ");
// Offset label highlight
push_span(&mut spans, chunk_idx, 0, 8, COLOR_OFFSET);
// Hex pairs with a gap after 8 bytes
for (i, &b) in chunk.iter().enumerate() {
if i == 8 {
line.push(' ');
}
let col = line.len();
push_span(&mut spans, chunk_idx, col, col + 2, byte_color(b));
write!(line, "{b:02x} ").unwrap();
}
// Pad if the last line is short
if chunk.len() < BYTES_PER_LINE {
let missing = BYTES_PER_LINE - chunk.len();
let mut pad = missing * 3;
if chunk.len() <= 8 {
pad += 1;
}
for _ in 0..pad {
line.push(' ');
}
}
// Separator before char panel
line.push(' ');
// Character panel — consecutive same-color chars merge automatically
let char_start = line.len();
for (i, &b) in chunk.iter().enumerate() {
let col = char_start + i;
push_span(&mut spans, chunk_idx, col, col + 1, byte_color(b));
line.push(byte_char(b));
}
lines.push(line);
}
(lines, spans)
}
/// Generate a hex dump for a binary file with paging support and highlight data.
///
/// Returns a Lua table:
/// ```text
/// {
/// lines: string[],
/// highlights: {line_0idx, col_start, col_end, color}[],
/// has_more: bool,
/// next_offset: number,
/// }
/// ```
pub fn hex_dump(
lua: &Lua,
(file_path, offset, length): (String, Option<u64>, Option<u64>),
) -> LuaResult<LuaValue> {
let offset = offset.unwrap_or(0);
let length = length.unwrap_or(4096);
let file = std::fs::File::open(&file_path)
.map_err(|e| LuaError::RuntimeError(format!("Failed to open file: {e}")))?;
let file_size = file
.metadata()
.map_err(|e| LuaError::RuntimeError(format!("Failed to get metadata: {e}")))?
.len();
let table = lua.create_table()?;
if offset >= file_size {
table.set("lines", lua.create_table()?)?;
table.set("highlights", lua.create_table()?)?;
table.set("has_more", false)?;
table.set("next_offset", file_size)?;
return Ok(LuaValue::Table(table));
}
let mut reader = std::io::BufReader::new(file);
reader
.seek(SeekFrom::Start(offset))
.map_err(|e| LuaError::RuntimeError(format!("Failed to seek: {e}")))?;
let mut raw_bytes = Vec::with_capacity(length as usize);
reader
.by_ref()
.take(length)
.read_to_end(&mut raw_bytes)
.map_err(|e| LuaError::RuntimeError(format!("Failed to read: {e}")))?;
let (plain_lines, hl_spans) = format_hex_dump(&raw_bytes, offset);
let lines_table = lua.create_table()?;
for (i, line) in plain_lines.iter().enumerate() {
lines_table.set(i + 1, line.as_str())?;
}
table.set("lines", lines_table)?;
let highlights_table = lua.create_table()?;
for (i, span) in hl_spans.iter().enumerate() {
let hl = lua.create_table()?;
hl.raw_set(1, span.line)?;
hl.raw_set(2, span.col_start)?;
hl.raw_set(3, span.col_end)?;
hl.raw_set(4, span.color)?;
highlights_table.raw_set(i + 1, hl)?;
}
table.set("highlights", highlights_table)?;
let bytes_read = raw_bytes.len() as u64;
let next_offset = offset + bytes_read;
table.set("has_more", next_offset < file_size)?;
table.set("next_offset", next_offset)?;
Ok(LuaValue::Table(table))
}
+111 -41
View File
@@ -1,11 +1,12 @@
use crate::path_shortening::shorten_path_with_cache;
use error::{IntoCoreError, IntoLuaResult};
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::query_tracker::QueryTracker;
use fff_core::{
DbHealthChecker, Error, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser,
SharedFrecency, SharedPicker, SharedQueryTracker,
use fff::file_picker::FilePicker;
use fff::frecency::FrecencyTracker;
use fff::path_utils::expand_tilde;
use fff::query_tracker::QueryTracker;
use fff::{
DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, PaginationArgs,
QueryParser, Score, SearchResult, SharedFrecency, SharedPicker, SharedQueryTracker,
};
use mimalloc::MiMalloc;
use mlua::prelude::*;
@@ -16,6 +17,7 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
mod error;
mod hex_dump;
mod log;
mod lua_types;
mod path_shortening;
@@ -46,7 +48,7 @@ pub fn init_db(
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 _ = FrecencyTracker::spawn_gc(Arc::clone(&FRECENCY), frecency_db_path, use_unsafe_no_lock);
let mut query_tracker = QUERY_TRACKER
.write()
@@ -56,10 +58,10 @@ pub fn init_db(
*query_tracker = None;
}
let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?;
*query_tracker = Some(tracker);
tracing::info!("Query tracker database initialized at {}", history_db_path);
*query_tracker =
Some(QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?);
tracing::info!("Query tracker database initialized at {}", history_db_path);
Ok(true)
}
@@ -143,7 +145,7 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
)));
}
let canonical_path = fff_core::path_utils::canonicalize(&path).map_err(|e| {
let canonical_path = fff::path_utils::canonicalize(&path).map_err(|e| {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
@@ -216,26 +218,16 @@ pub fn fuzzy_search_files(
let base_path = picker.base_path();
let min_combo_count = min_combo_count.unwrap_or(3);
let last_same_query_entry = {
let query_tracker = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
let query_tracker_guard = QUERY_TRACKER
.read()
.with_lock_error(Error::AcquireFrecencyLock)
.into_lua_result()?;
if query_tracker.as_ref().is_none() {
tracing::warn!("Query tracker not initialized");
}
query_tracker
.as_ref()
.map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count))
.transpose()
.into_lua_result()?
.flatten()
};
if query_tracker_guard.as_ref().is_none() {
tracing::warn!("Query tracker not initialized");
}
tracing::debug!(
?last_same_query_entry,
?base_path,
?query,
?min_combo_count,
@@ -244,19 +236,18 @@ pub fn fuzzy_search_files(
"Fuzzy search parameters"
);
// Parse the query once at the API boundary
let parser = QueryParser::default();
let parser = QueryParser::new(FileSearchConfig);
let parsed = parser.parse(&query);
let files = picker.get_files();
let results = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
files,
&parsed,
query_tracker_guard.as_ref(),
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
project_path: Some(picker.base_path()),
last_same_query_match: last_same_query_entry.as_ref(),
combo_boost_score_multiplier,
min_combo_count,
pagination: PaginationArgs {
@@ -266,6 +257,34 @@ pub fn fuzzy_search_files(
},
);
if results.items.is_empty() && query.contains(std::path::MAIN_SEPARATOR) {
let pure_query = match &parsed.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => t.trim(),
_ => query.trim(),
};
let path = expand_tilde(pure_query);
if path.is_absolute() && path.is_file() {
if let Ok(idx) = files.binary_search_by(|f| f.path.as_path().cmp(&path)) {
let found = SearchResult {
items: vec![&files[idx]],
scores: vec![Score {
exact_match: true,
match_type: "path",
..Default::default()
}],
total_matched: 1,
total_files: results.total_files,
location: parsed.location,
};
return lua_types::SearchResultLua::from(found).into_lua(lua);
}
return build_file_path_fallback(lua, &path, results.total_files);
}
}
lua_types::SearchResultLua::from(results).into_lua(lua)
}
@@ -300,15 +319,15 @@ pub fn live_grep(
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let parsed = fff_core::grep::parse_grep_query(&query);
let parsed = fff::grep::parse_grep_query(&query);
let mode = match grep_mode.as_deref() {
Some("regex") => fff_core::GrepMode::Regex,
Some("fuzzy") => fff_core::GrepMode::Fuzzy,
_ => fff_core::GrepMode::PlainText, // "plain" or nil or unknown
Some("regex") => fff::GrepMode::Regex,
Some("fuzzy") => fff::GrepMode::Fuzzy,
_ => fff::GrepMode::PlainText, // "plain" or nil or unknown
};
let options = fff_core::GrepSearchOptions {
let options = fff::GrepSearchOptions {
max_file_size: max_file_size.unwrap_or(10 * 1024 * 1024),
max_matches_per_file: max_matches_per_file.unwrap_or(200),
smart_case: smart_case.unwrap_or(true),
@@ -321,11 +340,61 @@ pub fn live_grep(
classify_definitions: false,
};
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed.as_ref(), &options);
let result = fff::grep::grep_search(picker.get_files(), &parsed, &options);
lua_types::GrepResultLua::from(result).into_lua(lua)
}
/// Build a file-picker result for an absolute path that exists on disk but
/// isn't in the picker index (e.g. file from a different project).
fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = path.to_string_lossy().to_string();
let item = lua.create_table()?;
item.set("path", path_str.as_str())?;
item.set("relative_path", path_str.as_str())?;
item.set("name", name.as_str())?;
item.set("size", path.metadata().map(|m| m.len()).unwrap_or(0))?;
item.set("modified", 0u64)?;
item.set("access_frecency_score", 0i64)?;
item.set("modification_frecency_score", 0i64)?;
item.set("total_frecency_score", 0i64)?;
item.set("git_status", "")?;
item.set("is_binary", false)?;
let items_table = lua.create_table()?;
items_table.set(1, item)?;
table.set("items", items_table)?;
let score = lua.create_table()?;
score.set("total", 0)?;
score.set("base_score", 0)?;
score.set("filename_bonus", 0)?;
score.set("special_filename_bonus", 0)?;
score.set("frecency_boost", 0)?;
score.set("git_status_boost", 0)?;
score.set("distance_penalty", 0)?;
score.set("current_file_penalty", 0)?;
score.set("combo_match_boost", 0)?;
score.set("exact_match", true)?;
score.set("match_type", "path")?;
let scores_table = lua.create_table()?;
scores_table.set(1, score)?;
table.set("scores", scores_table)?;
table.set("total_matched", 1)?;
table.set("total_files", total_files)?;
Ok(LuaValue::Table(table))
}
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
let file_path = PathBuf::from(&file_path);
@@ -481,7 +550,7 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
};
// Canonicalize the file path before spawning thread
let file_path = match fff_core::path_utils::canonicalize(&file_path) {
let file_path = match fff::path_utils::canonicalize(&file_path) {
Ok(path) => path,
Err(e) => {
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
@@ -827,6 +896,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
)?;
exports.set("health_check", lua.create_function(health_check)?)?;
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
exports.set("hex_dump", lua.create_function(hex_dump::hex_dump)?)?;
Ok(exports)
}
+1 -1
View File
@@ -1,3 +1,3 @@
//! Logging setup for fff-nvim — delegates to the shared fff-core::log utilities.
pub use fff_core::log::{init_tracing, install_panic_hook};
pub use fff::log::{init_tracing, install_panic_hook};
+2 -8
View File
@@ -1,12 +1,7 @@
//! Lua type conversions for fff-core types
//!
//! This module provides IntoLua implementations for core types.
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepResult, Location, Score, SearchResult};
use fff::git::format_git_status;
use fff::{FileItem, GrepResult, Location, Score, SearchResult};
use mlua::prelude::*;
/// Wrapper for SearchResult that implements IntoLua
pub struct SearchResultLua<'a> {
inner: SearchResult<'a>,
}
@@ -17,7 +12,6 @@ impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
}
}
/// Wrapper for GrepResult that implements IntoLua
pub struct GrepResultLua<'a> {
inner: GrepResult<'a>,
}
+4 -1
View File
@@ -1,7 +1,10 @@
[package]
name = "fff-query-parser"
version = "0.1.0"
version = "0.2.4"
edition = "2024"
description = "Query parser for fff file finder - includes specific syntax for various constraints like globs, extensions, regex etc"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
[lib]
path = "src/lib.rs"
@@ -98,7 +98,7 @@ fn bench_parse_various_lengths(c: &mut Criterion) {
}
fn bench_config_comparison(c: &mut Criterion) {
let file_picker = QueryParser::new(FilePickerConfig);
let file_picker = QueryParser::new(FileSearchConfig);
let grep = QueryParser::new(GrepConfig);
let query = "src name *.rs !test";
+33 -36
View File
@@ -73,6 +73,13 @@ pub trait ParserConfig {
true
}
/// Should parse location suffixes (e.g., file:12, file:12:4)
/// Disabled for grep modes where colon-number patterns like localhost:8080
/// are search text, not file locations.
fn enable_location(&self) -> bool {
true
}
/// Determine whether a token should be treated as a glob constraint.
///
/// The default implementation delegates to `zlob::has_wildcards` with
@@ -92,9 +99,9 @@ pub trait ParserConfig {
/// Default configuration for file picker - all features enabled
#[derive(Debug, Clone, Copy, Default)]
pub struct FilePickerConfig;
pub struct FileSearchConfig;
impl ParserConfig for FilePickerConfig {
impl ParserConfig for FileSearchConfig {
/// 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.
@@ -126,6 +133,10 @@ impl ParserConfig for GrepConfig {
false
}
fn enable_location(&self) -> bool {
false
}
/// Only recognise globs that are clearly directory/path oriented.
///
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
@@ -148,9 +159,17 @@ impl ParserConfig for GrepConfig {
return true;
}
// Brace expansion → useful for directory alternatives
if bytes.contains(&b'{') && bytes.contains(&b'}') {
return true;
// Brace expansion → useful for directory alternatives.
// Require a comma between `{` and `}` AND at least one letter to
// distinguish real glob expansions like `{src,lib}` or `*.{ts,tsx}`
// from code patterns like `format!("{}")` and regex quantifiers `{2,3}`.
if let Some(open) = bytes.iter().position(|&b| b == b'{')
&& let Some(close) = bytes.iter().rposition(|&b| b == b'}')
{
let inner = &bytes[open + 1..close];
if inner.contains(&b',') && inner.iter().any(|b| b.is_ascii_alphabetic()) {
return true;
}
}
// Everything else (?, [, bare * without /) → treat as literal text
@@ -161,10 +180,10 @@ impl ParserConfig for GrepConfig {
/// 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).
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
/// so the search is scoped to matching files. The caller validates the
/// constraint against the index and drops it if no files match (fallback).
#[derive(Debug, Clone, Copy, Default)]
pub struct AiGrepConfig;
@@ -177,6 +196,10 @@ impl ParserConfig for AiGrepConfig {
false
}
fn enable_location(&self) -> bool {
false
}
fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
@@ -202,36 +225,10 @@ impl ParserConfig for AiGrepConfig {
}
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_file_path_token(token) {
if is_filename_constraint_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,
}
}
+23 -39
View File
@@ -13,12 +13,13 @@
//!
//! let parser = QueryParser::default();
//!
//! // Single-token queries return None (no parsing needed)
//! // Single-token queries return FFFQuery with Text fuzzy query and no constraints
//! let result = parser.parse("hello");
//! assert!(result.is_none());
//! assert!(result.constraints.is_empty());
//! assert_eq!(result.fuzzy_query, FuzzyQuery::Text("hello"));
//!
//! // Multi-token queries are parsed
//! let result = parser.parse("name *.rs").expect("Should parse");
//! let result = parser.parse("name *.rs");
//! match &result.fuzzy_query {
//! FuzzyQuery::Text(text) => assert_eq!(*text, "name"),
//! _ => panic!("Expected text"),
@@ -26,11 +27,11 @@
//! assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
//!
//! // Parse glob pattern with text
//! let result = parser.parse("**/*.rs foo").expect("Should parse");
//! let result = parser.parse("**/*.rs foo");
//! assert!(matches!(result.constraints[0], Constraint::Glob("**/*.rs")));
//!
//! // Parse negation
//! let result = parser.parse("!*.rs foo").expect("Should parse");
//! let result = parser.parse("!*.rs foo");
//! match &result.constraints[0] {
//! Constraint::Not(inner) => {
//! assert!(matches!(inner.as_ref(), Constraint::Extension("rs")));
@@ -45,7 +46,7 @@ pub mod glob_detect;
pub mod location;
mod parser;
pub use config::{AiGrepConfig, FilePickerConfig, GrepConfig, ParserConfig};
pub use config::{AiGrepConfig, FileSearchConfig, GrepConfig, ParserConfig};
pub use constraints::{Constraint, GitStatusFilter};
pub use location::Location;
pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
@@ -53,7 +54,6 @@ pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
// Re-export SmallVec for convenience
pub use smallvec::SmallVec;
/// Type alias for constraint vector - stack-allocated for ≤8 constraints
pub type ConstraintVec<'a> = SmallVec<[Constraint<'a>; 8]>;
#[cfg(test)]
@@ -64,32 +64,30 @@ mod tests {
fn test_empty_query() {
let parser = QueryParser::default();
let result = parser.parse("");
// Empty query returns None (single-token behavior)
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Empty);
}
#[test]
fn test_whitespace_only() {
let parser = QueryParser::default();
let result = parser.parse(" ");
// Whitespace-only returns None
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Empty);
}
#[test]
fn test_single_token() {
let parser = QueryParser::default();
let result = parser.parse("hello");
// Single token returns None (no parsing needed)
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("hello"));
}
#[test]
fn test_simple_text() {
let parser = QueryParser::default();
let result = parser
.parse("hello world")
.expect("Should parse multi-token");
let result = parser.parse("hello world");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
@@ -107,9 +105,7 @@ mod tests {
fn test_extension_only() {
let parser = QueryParser::default();
// Single constraint token - returns Some so constraint can be applied
let result = parser
.parse("*.rs")
.expect("Should parse single constraint");
let result = parser.parse("*.rs");
assert!(matches!(result.fuzzy_query, FuzzyQuery::Empty));
assert_eq!(result.constraints.len(), 1);
assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
@@ -118,9 +114,7 @@ mod tests {
#[test]
fn test_glob_pattern() {
let parser = QueryParser::default();
let result = parser
.parse("**/*.rs foo")
.expect("Should parse multi-token");
let result = parser.parse("**/*.rs foo");
assert_eq!(result.constraints.len(), 1);
// Glob patterns with ** are treated as globs, not extensions
match &result.constraints[0] {
@@ -132,7 +126,7 @@ mod tests {
#[test]
fn test_negation_pattern() {
let parser = QueryParser::default();
let result = parser.parse("!test foo").expect("Should parse multi-token");
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -145,7 +139,7 @@ mod tests {
#[test]
fn test_path_segment() {
let parser = QueryParser::default();
let result = parser.parse("/src/ foo").expect("Should parse multi-token");
let result = parser.parse("/src/ foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -156,9 +150,7 @@ mod tests {
#[test]
fn test_git_status() {
let parser = QueryParser::default();
let result = parser
.parse("status:modified foo")
.expect("Should parse multi-token");
let result = parser.parse("status:modified foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -169,9 +161,7 @@ mod tests {
#[test]
fn test_file_type() {
let parser = QueryParser::default();
let result = parser
.parse("type:rust foo")
.expect("Should parse multi-token");
let result = parser.parse("type:rust foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -182,9 +172,7 @@ mod tests {
#[test]
fn test_complex_query() {
let parser = QueryParser::default();
let result = parser
.parse("src name *.rs !test /lib/ status:modified")
.expect("Should parse");
let result = parser.parse("src name *.rs !test /lib/ status:modified");
// Verify we have fuzzy text
match &result.fuzzy_query {
@@ -226,9 +214,7 @@ mod tests {
#[test]
fn test_no_heap_allocation_for_small_queries() {
let parser = QueryParser::default();
let result = parser
.parse("*.rs *.toml !test")
.expect("Should parse multi-token");
let result = parser.parse("*.rs *.toml !test");
// SmallVec should not have spilled to heap
assert!(!result.constraints.spilled());
}
@@ -236,9 +222,7 @@ mod tests {
#[test]
fn test_many_fuzzy_parts() {
let parser = QueryParser::default();
let result = parser
.parse("one two three four five six")
.expect("Should parse");
let result = parser.parse("one two three four five six");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
+220 -178
View File
@@ -14,6 +14,8 @@ pub enum FuzzyQuery<'a> {
#[derive(Debug, Clone, PartialEq)]
pub struct FFFQuery<'a> {
/// The original raw query string before parsing
pub raw_query: &'a str,
/// Parsed constraints (stack-allocated for ≤8 constraints)
pub constraints: ConstraintVec<'a>,
pub fuzzy_query: FuzzyQuery<'a>,
@@ -32,8 +34,8 @@ impl<C: ParserConfig> QueryParser<C> {
Self { config }
}
pub fn parse<'a>(&self, query: &'a str) -> Option<FFFQuery<'a>> {
let query: &'a str = query;
pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
let raw_query = query;
let config: &C = &self.config;
let mut constraints = ConstraintVec::new();
let query = query.trim();
@@ -47,28 +49,53 @@ impl<C: ParserConfig> QueryParser<C> {
// 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(_)) {
//
// Also skip PathSegment constraints when the token looks like an absolute
// file path with a location suffix (e.g. /Users/.../file.rs:12). Without
// this, the leading `/` causes the entire path to be consumed as a
// PathSegment, preventing location parsing from running.
let has_location_suffix = matches!(constraint, Constraint::PathSegment(_))
&& query.bytes().any(|b| b == b':')
&& query
.bytes()
.rev()
.take_while(|&b| b != b':')
.all(|b| b.is_ascii_digit());
if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {
constraints.push(constraint);
return Some(FFFQuery {
return FFFQuery {
raw_query,
constraints,
fuzzy_query: FuzzyQuery::Empty,
location: None,
});
};
}
}
// Try to extract location from single token (e.g., "file:12")
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
});
if config.enable_location() {
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return FFFQuery {
raw_query,
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
};
}
}
// Plain text single token - return None (caller handles as simple fuzzy match)
return None;
// Plain text single token
return FFFQuery {
raw_query,
constraints,
fuzzy_query: if query.is_empty() {
FuzzyQuery::Empty
} else {
FuzzyQuery::Text(query)
},
location: None,
};
}
let mut text_parts = TextPartsBuffer::new();
@@ -99,7 +126,7 @@ impl<C: ParserConfig> QueryParser<C> {
// Try to extract location from the last fuzzy token
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
let location = if !text_parts.is_empty() {
let location = if config.enable_location() && !text_parts.is_empty() {
let last_idx = text_parts.len() - 1;
let (without_loc, loc) = parse_location(text_parts[last_idx]);
if loc.is_some() {
@@ -131,11 +158,12 @@ impl<C: ParserConfig> QueryParser<C> {
}
};
Some(FFFQuery {
FFFQuery {
raw_query,
constraints,
fuzzy_query,
location,
})
}
}
}
@@ -180,9 +208,9 @@ fn strip_leading_backslash(token: &str) -> &str {
token
}
impl Default for QueryParser<crate::FilePickerConfig> {
impl Default for QueryParser<crate::FileSearchConfig> {
fn default() -> Self {
Self::new(crate::FilePickerConfig)
Self::new(crate::FileSearchConfig)
}
}
@@ -409,7 +437,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{FilePickerConfig, GrepConfig};
use crate::{FileSearchConfig, GrepConfig};
#[test]
fn test_parse_extension() {
@@ -424,7 +452,7 @@ mod tests {
#[test]
fn test_incomplete_patterns_ignored() {
let config = FilePickerConfig;
let config = FileSearchConfig;
// Incomplete patterns should return None and be treated as noise
assert_eq!(parse_token("*", &config), None);
assert_eq!(parse_token("*.", &config), None);
@@ -468,10 +496,8 @@ mod tests {
#[test]
fn test_trailing_slash_in_query() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("www/ test")
.expect("Should parse multi-token query");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("www/ test");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -506,11 +532,9 @@ mod tests {
#[test]
fn test_negation_text() {
let parser = QueryParser::new(FilePickerConfig);
let parser = QueryParser::new(FileSearchConfig);
// Need two tokens for parsing to return Some
let result = parser
.parse("!test foo")
.expect("Should parse multi-token query");
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -522,10 +546,8 @@ mod tests {
#[test]
fn test_negation_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!*.rs foo")
.expect("Should parse multi-token query");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("!*.rs foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -537,10 +559,8 @@ mod tests {
#[test]
fn test_negation_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!/src/ foo")
.expect("Should parse multi-token query");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("!/src/ foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -552,10 +572,8 @@ mod tests {
#[test]
fn test_negation_git_status() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!status:modified foo")
.expect("Should parse multi-token query");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("!status:modified foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -570,10 +588,8 @@ mod tests {
#[test]
fn test_backslash_escape_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\*.rs foo")
.expect("Should parse multi-token query");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("\\*.rs foo");
// \*.rs should NOT be parsed as an Extension constraint
assert_eq!(result.constraints.len(), 0);
// Both tokens should be text
@@ -589,10 +605,8 @@ mod tests {
#[test]
fn test_backslash_escape_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\/src/ foo")
.expect("Should parse multi-token query");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("\\/src/ foo");
assert_eq!(result.constraints.len(), 0);
match result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
@@ -605,118 +619,91 @@ mod tests {
#[test]
fn test_backslash_escape_negation() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\!test foo")
.expect("Should parse multi-token query");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("\\!test foo");
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_grep_text_plain_text() {
// Multi-token plain text — no constraints
let q = QueryParser::new(GrepConfig)
.parse("name =")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name =");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_strips_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name = *.rs someth")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
assert_eq!(q.grep_text(), "name = someth");
}
#[test]
fn test_grep_text_leading_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("*.rs name =")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("*.rs name =");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_only_constraints() {
let q = QueryParser::new(GrepConfig)
.parse("*.rs /src/")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
assert_eq!(q.grep_text(), "");
}
#[test]
fn test_grep_text_path_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name /src/ value")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name /src/ value");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_negation_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name !*.rs value")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_backslash_escape_stripped() {
// \*.rs should be text with the leading \ removed
let q = QueryParser::new(GrepConfig)
.parse("\\*.rs foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
assert_eq!(q.grep_text(), "*.rs foo");
let q = QueryParser::new(GrepConfig)
.parse("\\/src/ foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
assert_eq!(q.grep_text(), "/src/ foo");
let q = QueryParser::new(GrepConfig)
.parse("\\!test foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\!test foo");
assert_eq!(q.grep_text(), "!test foo");
}
#[test]
fn test_grep_text_question_mark_is_text() {
let q = QueryParser::new(GrepConfig)
.parse("foo? bar")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("foo? bar");
assert_eq!(q.grep_text(), "foo? bar");
}
#[test]
fn test_grep_text_bracket_is_text() {
let q = QueryParser::new(GrepConfig)
.parse("arr[0] more")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("arr[0] more");
assert_eq!(q.grep_text(), "arr[0] more");
}
#[test]
fn test_grep_text_path_glob_is_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("pattern src/**/*.rs")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
assert_eq!(q.grep_text(), "pattern");
}
#[test]
fn test_grep_question_mark_is_text() {
let parser = QueryParser::new(GrepConfig);
// Single token "foo?" should return None (treated as plain text by caller)
let result = parser.parse("foo?");
assert!(result.is_none(), "foo? should be plain text in grep mode");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
}
#[test]
fn test_grep_bracket_is_text() {
let parser = QueryParser::new(GrepConfig);
let result = parser.parse("arr[0] something");
let result = result.expect("Should parse multi-token query");
// arr[0] should NOT be a glob in grep mode
assert_eq!(result.constraints.len(), 0);
}
@@ -724,9 +711,7 @@ mod tests {
#[test]
fn test_grep_path_glob_is_constraint() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern src/**/*.rs")
.expect("Should parse with path glob");
let result = parser.parse("pattern src/**/*.rs");
// src/**/*.rs contains / so it should be treated as a glob
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
@@ -738,9 +723,7 @@ mod tests {
#[test]
fn test_grep_brace_is_constraint() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern {src,lib}")
.expect("Should parse with brace expansion");
let result = parser.parse("pattern {src,lib}");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -753,45 +736,34 @@ mod tests {
// 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");
let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
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");
let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
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).
// Single-token regex like "fn\\s+\\w+" returns FFFQuery with Text fuzzy query
let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
assert!(
result.is_none(),
"Single-token regex should return None (no parsing)"
);
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
// But the escaped constraint forms SHOULD still be stripped:
let q = QueryParser::new(GrepConfig)
.parse("\\*.rs foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
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");
let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
assert_eq!(
q.grep_text(),
"/src/ foo",
@@ -804,7 +776,6 @@ mod tests {
let parser = QueryParser::new(GrepConfig);
// "a*b" contains * but no / or {} — should be text in grep mode
let result = parser.parse("a*b something");
let result = result.expect("Should parse");
assert_eq!(
result.constraints.len(),
0,
@@ -815,9 +786,7 @@ mod tests {
#[test]
fn test_grep_negated_text() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !test")
.expect("Should parse negated text in grep mode");
let result = parser.parse("pattern !test");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -834,9 +803,7 @@ mod tests {
#[test]
fn test_grep_negated_path_segment() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !/src/")
.expect("Should parse negated path segment in grep mode");
let result = parser.parse("pattern !/src/");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -853,9 +820,7 @@ mod tests {
#[test]
fn test_grep_negated_extension() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !*.rs")
.expect("Should parse negated extension in grep mode");
let result = parser.parse("pattern !*.rs");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -875,9 +840,7 @@ mod tests {
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");
let result = parser.parse("libswscale/input.c rgba32ToY");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
@@ -894,7 +857,7 @@ mod tests {
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");
let result = parser.parse("src/main.rs fn main");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -907,7 +870,7 @@ mod tests {
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");
let result = parser.parse("src/ pattern");
// Should be PathSegment, NOT FilePath
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -918,20 +881,49 @@ mod tests {
}
#[test]
fn test_ai_grep_no_false_positive_no_slash() {
fn test_ai_grep_bare_filename_is_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("main.rs pattern").expect("Should parse");
// No slash → not a file path, just text
let result = parser.parse("main.rs pattern");
// Bare filename with valid extension → FilePath constraint
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
"Expected FilePath, got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "pattern");
}
#[test]
fn test_ai_grep_bare_filename_schema_rs() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("schema.rs part_revisions");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
"Expected FilePath(schema.rs), got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "part_revisions");
}
#[test]
fn test_ai_grep_bare_word_no_extension_not_constraint() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("schema pattern");
// No extension → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "main.rs pattern");
assert_eq!(result.grep_text(), "schema pattern");
}
#[test]
fn test_ai_grep_no_false_positive_no_extension() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/utils pattern").expect("Should parse");
let result = parser.parse("src/utils pattern");
// 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");
@@ -941,7 +933,7 @@ mod tests {
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");
let result = parser.parse("src/**/*.rs pattern");
// Contains wildcards → should be a Glob, not FilePath
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -955,7 +947,7 @@ mod tests {
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");
let result = parser.parse("*quote* TODO");
// `*quote*` should be recognised as a glob constraint in AI mode
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -970,7 +962,7 @@ mod tests {
fn test_ai_grep_bare_star_not_glob() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("* pattern").expect("Should parse");
let result = parser.parse("* pattern");
// Bare `*` should NOT be treated as a glob (too broad)
assert!(
result.constraints.is_empty(),
@@ -979,11 +971,62 @@ mod tests {
);
}
#[test]
fn test_grep_no_location_parsing_single_token() {
let parser = QueryParser::new(GrepConfig);
// localhost:8080 should NOT be parsed as location -- it's a search pattern
let result = parser.parse("localhost:8080");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
}
#[test]
fn test_grep_no_location_parsing_multi_token() {
let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
assert_eq!(
q.grep_text(),
"localhost:8080",
"Colon-number suffix should be preserved in grep text"
);
assert!(
q.location.is_none(),
"Grep should not parse location from colon-number"
);
}
#[test]
fn test_grep_braces_without_comma_is_text() {
let parser = QueryParser::new(GrepConfig);
// Code patterns like format!("{}") should NOT be treated as brace expansion
let result = parser.parse(r#"format!("{}\\AppData", home)"#);
assert!(
result.constraints.is_empty(),
"Braces without comma should be text, got {:?}",
result.constraints
);
assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
}
#[test]
fn test_grep_format_braces_not_glob() {
let parser = QueryParser::new(GrepConfig);
// Code like format!("{}\\path", var) must not have tokens eaten as glob constraints.
// The trailing comma on the first token means both { } and , are present,
// but the comma is outside the braces so it should NOT trigger brace expansion.
let input = "format!(\"{}\\\\AppData\", home)";
let result = parser.parse(input);
assert!(
result.constraints.is_empty(),
"format! pattern should have 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");
let result = parser.parse("*quote* TODO");
// Regular grep mode should NOT treat `*quote*` as a glob
assert!(
result.constraints.is_empty(),
@@ -996,10 +1039,8 @@ mod 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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("score.rs file_picker");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("score.rs")),
@@ -1011,10 +1052,8 @@ mod tests {
#[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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("libswscale/slice.c lum_convert");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
@@ -1029,23 +1068,36 @@ mod tests {
#[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 parser = QueryParser::new(FileSearchConfig);
// Single-token filename should NOT become a constraint -- it should
// return FFFQuery with Text fuzzy query so the caller uses it for fuzzy matching.
let result = parser.parse("score.rs");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
}
#[test]
fn test_absolute_path_with_location_not_path_segment() {
let parser = QueryParser::new(FileSearchConfig);
// Absolute file path with :line should parse as text + location,
// NOT as a PathSegment constraint (which would eat the whole token).
let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
assert!(
result.is_none(),
"Single-token filename should return None (fuzzy match), got {:?}",
result
result.constraints.is_empty(),
"Absolute path with location should not become a constraint, got {:?}",
result.constraints
);
assert_eq!(
result.fuzzy_query,
FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
);
assert_eq!(result.location, Some(Location::Line(12)));
}
#[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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("main.rs src components");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -1059,10 +1111,8 @@ mod tests {
#[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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("v2.0 release");
// v2.0 extension starts with digit → not a filename constraint
assert!(
result.constraints.is_empty(),
@@ -1073,10 +1123,8 @@ mod tests {
#[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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("main.rs score.rs");
// Only first filename becomes a constraint; second is text
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
@@ -1088,10 +1136,8 @@ mod tests {
#[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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("main.rs *.lua");
// main.rs → FilePath, *.lua → Extension
assert_eq!(result.constraints.len(), 2);
assert!(matches!(
@@ -1106,10 +1152,8 @@ mod tests {
#[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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse(".gitignore src");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
@@ -1121,10 +1165,8 @@ mod tests {
#[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");
let parser = QueryParser::new(FileSearchConfig);
let result = parser.parse("Makefile src");
// No dot → not a filename constraint
assert!(
result.constraints.is_empty(),
+2 -2
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 12
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 18
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -38,7 +38,7 @@ script:
<
The installation script is here ./install-fff.sh <./install-fff.sh> if you want
The installation script is here ./install-mcp.sh <./install-mcp.sh> if you want
to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`,
`Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1767744144,
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
"lastModified": 1773189535,
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
"owner": "ipetkov",
"repo": "crane",
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"lastModified": 1773597492,
"narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1770865833,
"narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=",
"lastModified": 1773716879,
"narHash": "sha256-vXCTasEzzTTd0ZGEuyle20H2hjRom66JeNr7i2ktHD0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85",
"rev": "1a9ddeb45c5751b800331363703641b84d1f41f0",
"type": "github"
},
"original": {
+24 -6
View File
@@ -126,7 +126,9 @@ download_binary() {
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}"
if [ "$IS_UPDATE" != true ]; then
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
fi
}
check_path() {
@@ -236,19 +238,35 @@ print_setup_instructions() {
}
main() {
info "Installing FFF MCP Server..."
echo ""
local target
target="$(detect_platform)"
local existing_binary="${INSTALL_DIR}/${BINARY_NAME}"
IS_UPDATE=false
if [ -x "$existing_binary" ]; then
IS_UPDATE=true
info "Updating FFF MCP Server..."
else
info "Installing FFF MCP Server..."
fi
echo ""
info "Detected platform: ${target}"
local tag
tag="$(get_latest_release_tag "$target")"
download_binary "$target" "$tag"
check_path
print_setup_instructions
if [ "$IS_UPDATE" = true ]; then
echo ""
success "FFF MCP Server updated to ${tag}!"
echo ""
else
check_path
print_setup_instructions
fi
}
main
+107 -42
View File
@@ -2,6 +2,7 @@ local utils = require('fff.utils')
local file_picker = require('fff.file_picker')
local image = require('fff.file_picker.image')
local location_utils = require('fff.location_utils')
local rust = require('fff.rust')
local M = {}
@@ -301,6 +302,8 @@ M.state = {
location = nil, -- Current location data for highlighting
location_namespace = nil, -- Namespace for location highlighting
preview_generation = 0, -- Monotonically increasing token to detect stale async callbacks
is_binary_preview = false, -- Whether the current preview is a hex dump
hex_byte_offset = 0, -- Next byte offset for hex dump paging
}
--- Setup preview configuration
@@ -554,7 +557,69 @@ function M.preview_file(file_path, bufnr)
return true
end
--- Preview a binary file with async file type detection
-- Hex preview highlight support: dynamically create hl groups from "#rrggbb"
local hex_ns = nil
local hex_hl_cache = {}
local function ensure_hex_ns()
if not hex_ns then hex_ns = vim.api.nvim_create_namespace('fff_hex_preview') end
return hex_ns
end
local function get_hex_hl_group(hex_color)
local cached = hex_hl_cache[hex_color]
if cached then return cached end
local group = 'FffHex_' .. hex_color:sub(2)
vim.api.nvim_set_hl(0, group, { fg = hex_color })
hex_hl_cache[hex_color] = group
return group
end
--- Apply hex highlight spans to a buffer
--- @param bufnr number Buffer number
--- @param highlights table Array of {line_0idx, col_start, col_end, "#rrggbb"}
--- @param line_offset number Lines to add to each highlight line index (for header)
local function apply_hex_highlights(bufnr, highlights, line_offset)
if not highlights or not vim.api.nvim_buf_is_valid(bufnr) then return end
local ns = ensure_hex_ns()
for _, hl in ipairs(highlights) do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hl[1] + line_offset, hl[2], {
end_col = hl[3],
hl_group = get_hex_hl_group(hl[4]),
})
end
end
--- Load a page of hex dump content from the Rust backend
--- @param file_path string Path to the binary file
--- @param byte_offset number Byte offset to start reading from
--- @return table|nil Result with lines, highlights, has_more, next_offset
local function load_hex_page(file_path, byte_offset)
local ok, result = pcall(rust.hex_dump, file_path, byte_offset, 4096)
if ok and result then return result end
return nil
end
--- Load more hex content when scrolling near the end of the buffer
local function load_more_hex_content()
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
if not M.state.has_more_content or not M.state.current_file then return end
local current_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local result = load_hex_page(M.state.current_file, M.state.hex_byte_offset)
if result and result.lines and #result.lines > 0 then
append_buffer_lines(M.state.bufnr, result.lines)
M.state.hex_byte_offset = result.next_offset
M.state.has_more_content = result.has_more
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
M.state.loaded_lines = M.state.content_height
apply_hex_highlights(M.state.bufnr, result.highlights, current_lines)
else
M.state.has_more_content = false
end
end
--- Preview a binary file using hexyl-powered hex dump with paging
--- @param file_path string Path to the file
--- @param bufnr number Buffer number for preview
--- @return boolean Success status
@@ -562,49 +627,40 @@ function M.preview_binary_file(file_path, bufnr)
local info = M.get_file_info(file_path)
local lines = {}
M.state.is_binary_preview = true
M.state.hex_byte_offset = 0
-- Build header synchronously (file -b is fast, typically <10ms)
if vim.fn.executable('file') == 1 then
local output = vim.fn.system({ 'file', '-b', file_path })
if vim.v.shell_error == 0 and output then
local file_type = output:gsub('\n', '')
table.insert(lines, 'Binary file: ' .. file_type)
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
table.insert(lines, '')
end
end
local hex_result = load_hex_page(file_path, 0)
if hex_result and hex_result.lines then
for _, hex_line in ipairs(hex_result.lines) do
table.insert(lines, hex_line)
end
M.state.hex_byte_offset = hex_result.next_offset
M.state.has_more_content = hex_result.has_more
end
set_buffer_lines(bufnr, lines)
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
if vim.fn.executable('file') == 1 then
local cmd = { 'file', '-b', file_path }
vim.system(cmd, { text = true }, function(result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
M.state.content_height = #lines
M.state.loaded_lines = #lines
if result.code == 0 and result.stdout then
local file_type = result.stdout:gsub('\n', '')
table.insert(lines, 'Binary file: ' .. file_type)
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
if vim.fn.executable('xxd') == 1 then
table.insert(lines, '')
set_buffer_lines(bufnr, lines)
local hex_cmd = { 'xxd', '-l', '8192', file_path }
vim.system(hex_cmd, { text = true }, function(hex_result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
if hex_result.code == 0 and hex_result.stdout then
local hex_lines = vim.split(hex_result.stdout, '\n')
for _, line in ipairs(hex_lines) do
if line:match('%S') then table.insert(lines, line) end
end
else
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
end
set_buffer_lines(bufnr, lines)
end)
end)
else
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
set_buffer_lines(bufnr, lines)
end
end
end)
end)
if hex_result and hex_result.highlights then
local header_lines = #lines - (hex_result.lines and #hex_result.lines or 0)
apply_hex_highlights(bufnr, hex_result.highlights, header_lines)
end
return true
@@ -641,6 +697,9 @@ function M.preview(file_path, bufnr, location, is_binary)
M.state.total_file_lines = nil
M.state.has_more_content = true
M.state.is_loading = false
M.state.hex_byte_offset = 0
M.state.is_binary_preview = false
M.state.current_file = file_path
M.state.bufnr = bufnr
@@ -672,15 +731,19 @@ function M.scroll(lines)
-- If scrolling down and approaching end of loaded content, try to load more
if lines > 0 and not M.state.is_loading then
local target_line = new_offset + win_height
local buffer_needed = target_line + 20 -- Load a bit ahead
local buffer_needed = target_line + 20
if current_buffer_lines < buffer_needed and M.state.has_more_content then
-- Load more content asynchronously but don't wait for it
ensure_content_loaded_async(target_line)
if M.state.is_binary_preview then
load_more_hex_content()
-- Re-read line count after loading more
current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
else
ensure_content_loaded_async(target_line)
end
end
end
-- Use actual buffer line count for scroll calculations
local content_height = current_buffer_lines
local half_screen = math.floor(win_height / 2)
local max_scroll = math.max(0, content_height + half_screen - win_height)
@@ -802,6 +865,8 @@ function M.clear()
M.state.scroll_offset = 0
M.state.content_height = 0
M.state.location = nil
M.state.is_binary_preview = false
M.state.hex_byte_offset = 0
end
--- Apply location highlighting to the preview buffer
+7 -25
View File
@@ -165,11 +165,12 @@ function M.find_files_in_dir(directory)
return
end
M.change_indexing_directory(directory)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
picker_ui.open({
title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t'),
cwd = directory,
})
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
@@ -179,28 +180,9 @@ end
--- @param new_path string New directory path to use as base
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path)
if not new_path or new_path == '' then
vim.notify('Directory path is required', vim.log.levels.ERROR)
return false
end
local expanded_path = vim.fn.expand(new_path)
if vim.fn.isdirectory(expanded_path) ~= 1 then
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
return false
end
local fuzzy = require('fff.core').ensure_initialized()
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
if not ok then
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
return false
end
local config = require('fff.conf').get()
config.base_path = expanded_path
return true
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then return picker_ui.change_indexing_directory(new_path) end
return false
end
--- Opens the file under the cursor with an optional callback if the only file
+64
View File
@@ -1700,6 +1700,23 @@ function M.update_preview()
-- Check if we need to update the preview (file changed OR location changed)
local effective_location = M.state.location
-- Fallback: if location is nil but query has a :line suffix, parse it directly
if not effective_location and M.state.query and M.state.query ~= '' then
local line_str = M.state.query:match(':(%d+)$')
if line_str then
local line_num = tonumber(line_str)
if line_num and line_num > 0 then
local l, c = M.state.query:match(':(%d+):(%d+)$')
if l then
effective_location = { line = tonumber(l), col = tonumber(c) }
else
effective_location = { line = line_num }
end
end
end
end
-- In grep mode (or when previewing grep suggestions), location comes from the match item
local is_grep_item = M.state.mode == 'grep' or M.state.suggestion_source == 'grep'
if is_grep_item and item.line_number and item.line_number > 0 then
@@ -2293,6 +2310,23 @@ function M.select(action)
end
end
-- Fallback: if location is nil but query has a :line suffix, parse it directly
if not location and query and query ~= '' then
local line_str = query:match(':(%d+)$')
if line_str then
local line_num = tonumber(line_str)
if line_num and line_num > 0 then
local col_and_line = query:match(':(%d+):(%d+)$')
if col_and_line then
local l, c = query:match(':(%d+):(%d+)$')
location = { line = tonumber(l), col = tonumber(c) }
else
location = { line = line_num }
end
end
end
end
vim.cmd('stopinsert')
M.close()
@@ -2601,6 +2635,8 @@ function M.open(opts)
local merged_config, base_path = initialize_picker(opts)
if not merged_config then return end
if base_path then M.change_indexing_directory(base_path) end
-- Initialize grep_mode to first configured mode when opening in grep mode
if M.state.mode == 'grep' then
-- Use grep_config.modes if provided, otherwise fall back to global config
@@ -2616,6 +2652,34 @@ function M.open(opts)
return open_ui_with_state(query, nil, nil, merged_config, current_file_cache)
end
--- Change the base directory for the file picker
--- @param new_path string New directory path to use as base
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path)
if not new_path or new_path == '' then
vim.notify('Directory path is required', vim.log.levels.ERROR)
return false
end
local expanded_path = vim.fn.expand(new_path)
if vim.fn.isdirectory(expanded_path) ~= 1 then
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
return false
end
local fuzzy = require('fff.core').ensure_initialized()
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
if not ok then
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
return false
end
local config = require('fff.conf').get()
config.base_path = expanded_path
return true
end
function M.monitor_scan_progress(iteration)
if not M.state.active then return end
+709
View File
@@ -0,0 +1,709 @@
{
"name": "fff.nvim",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"workspaces": [
"packages/fff-bun",
"packages/fff-node"
],
"devDependencies": {
"@biomejs/biome": "^2.4.4"
}
},
"node_modules/@biomejs/biome": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz",
"integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.7",
"@biomejs/cli-darwin-x64": "2.4.7",
"@biomejs/cli-linux-arm64": "2.4.7",
"@biomejs/cli-linux-arm64-musl": "2.4.7",
"@biomejs/cli-linux-x64": "2.4.7",
"@biomejs/cli-linux-x64-musl": "2.4.7",
"@biomejs/cli-win32-arm64": "2.4.7",
"@biomejs/cli-win32-x64": "2.4.7"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz",
"integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz",
"integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz",
"integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz",
"integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz",
"integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz",
"integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz",
"integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz",
"integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@ff-labs/fff-bun": {
"resolved": "packages/fff-bun",
"link": true
},
"node_modules/@ff-labs/fff-node": {
"resolved": "packages/fff-node",
"link": true
},
"node_modules/@oven/bun-darwin-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
"integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-darwin-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz",
"integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-darwin-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz",
"integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@oven/bun-linux-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz",
"integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-aarch64-musl": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz",
"integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz",
"integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz",
"integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz",
"integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz",
"integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@oven/bun-windows-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz",
"integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@oven/bun-windows-x64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz",
"integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@oven/bun-windows-x64-baseline": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz",
"integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@types/bun": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.10.tgz",
"integrity": "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.3.10"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@yuuang/ffi-rs-android-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.1.tgz",
"integrity": "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.1.tgz",
"integrity": "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.1.tgz",
"integrity": "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.1.tgz",
"integrity": "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.1.tgz",
"integrity": "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.1.tgz",
"integrity": "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.1.tgz",
"integrity": "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.1.tgz",
"integrity": "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.1.tgz",
"integrity": "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.1.tgz",
"integrity": "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg==",
"cpu": [
"x64",
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.1.tgz",
"integrity": "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/bun": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz",
"integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==",
"cpu": [
"arm64",
"x64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"peer": true,
"bin": {
"bun": "bin/bun.exe",
"bunx": "bin/bunx.exe"
},
"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"
}
},
"node_modules/bun-types": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.10.tgz",
"integrity": "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/ffi-rs": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.1.tgz",
"integrity": "sha512-ZyNXL9fnclnZV+waQmWB9JrfbIEyxQa1OWtMrHOrAgcC04PgP5hBMG5TdhVN8N4uT/eul8zCFMVnJUukAFFlXA==",
"license": "MIT",
"optionalDependencies": {
"@yuuang/ffi-rs-android-arm64": "1.3.1",
"@yuuang/ffi-rs-darwin-arm64": "1.3.1",
"@yuuang/ffi-rs-darwin-x64": "1.3.1",
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.1",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.1",
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.1",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.1",
"@yuuang/ffi-rs-linux-x64-musl": "1.3.1",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.1",
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.1",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.1"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"packages/fff-bun": {
"name": "@ff-labs/fff-bun",
"version": "0.1.37",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"fff-demo": "examples/search.ts",
"fff-grep": "examples/grep.ts"
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0"
},
"engines": {
"bun": ">=1.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bin-win32-arm64": "0.0.0",
"@ff-labs/fff-bin-win32-x64": "0.0.0"
},
"peerDependencies": {
"bun": ">=1.0.0"
}
},
"packages/fff-node": {
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"ffi-rs": "^1.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bin-win32-arm64": "0.0.0",
"@ff-labs/fff-bin-win32-x64": "0.0.0"
}
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"private": true,
"workspaces": ["packages/fff-bun", "packages/fff-mcp"],
"packageManager": "bun@1.3.9",
"workspaces": ["packages/fff-bun", "packages/fff-node"],
"scripts": {
"format": "biome format --write",
"format:check": "biome format",
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-darwin-arm64",
"name": "@ff-labs/fff-bin-darwin-arm64",
"version": "0.0.0",
"description": "fff native binary for macOS ARM64 (Apple Silicon)",
"os": ["darwin"],
@@ -13,6 +13,6 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-darwin-arm64"
"directory": "packages/fff-bin-darwin-arm64"
}
}
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-darwin-x64",
"name": "@ff-labs/fff-bin-darwin-x64",
"version": "0.0.0",
"description": "fff native binary for macOS x64 (Intel)",
"os": ["darwin"],
@@ -13,6 +13,6 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-darwin-x64"
"directory": "packages/fff-bin-darwin-x64"
}
}
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-linux-arm64-gnu",
"name": "@ff-labs/fff-bin-linux-arm64-gnu",
"version": "0.0.0",
"description": "fff native binary for Linux ARM64 (glibc)",
"os": ["linux"],
@@ -13,7 +13,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-arm64-gnu"
"directory": "packages/fff-bin-linux-arm64-gnu"
},
"libc": ["glibc"]
}
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-linux-arm64-musl",
"name": "@ff-labs/fff-bin-linux-arm64-musl",
"version": "0.0.0",
"description": "fff native binary for Linux ARM64 (musl)",
"os": ["linux"],
@@ -13,7 +13,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-arm64-musl"
"directory": "packages/fff-bin-linux-arm64-musl"
},
"libc": ["musl"]
}
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-linux-x64-gnu",
"name": "@ff-labs/fff-bin-linux-x64-gnu",
"version": "0.0.0",
"description": "fff native binary for Linux x64 (glibc)",
"os": ["linux"],
@@ -13,7 +13,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-x64-gnu"
"directory": "packages/fff-bin-linux-x64-gnu"
},
"libc": ["glibc"]
}
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-linux-x64-musl",
"name": "@ff-labs/fff-bin-linux-x64-musl",
"version": "0.0.0",
"description": "fff native binary for Linux x64 (musl)",
"os": ["linux"],
@@ -13,7 +13,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-linux-x64-musl"
"directory": "packages/fff-bin-linux-x64-musl"
},
"libc": ["musl"]
}
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-win32-arm64",
"name": "@ff-labs/fff-bin-win32-arm64",
"version": "0.0.0",
"description": "fff native binary for Windows ARM64",
"os": ["win32"],
@@ -13,6 +13,6 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-win32-arm64"
"directory": "packages/fff-bin-win32-arm64"
}
}
@@ -1,5 +1,5 @@
{
"name": "@ff-labs/fff-bun-win32-x64",
"name": "@ff-labs/fff-bin-win32-x64",
"version": "0.0.0",
"description": "fff native binary for Windows x64",
"os": ["win32"],
@@ -13,6 +13,6 @@
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-bun-win32-x64"
"directory": "packages/fff-bin-win32-x64"
}
}
+18 -18
View File
@@ -16,20 +16,20 @@ High-performance fuzzy file finder for Bun, powered by Rust. Perfect for LLM age
bun add @ff-labs/bun
```
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bun-darwin-arm64`, `@ff-labs/fff-bun-linux-x64-gnu`). No GitHub downloads are needed.
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`). No GitHub downloads are needed.
### Supported Platforms
| Platform | Architecture | Package |
|----------|-------------|---------|
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bun-darwin-arm64` |
| macOS | x64 (Intel) | `@ff-labs/fff-bun-darwin-x64` |
| Linux | x64 (glibc) | `@ff-labs/fff-bun-linux-x64-gnu` |
| Linux | ARM64 (glibc) | `@ff-labs/fff-bun-linux-arm64-gnu` |
| Linux | x64 (musl) | `@ff-labs/fff-bun-linux-x64-musl` |
| Linux | ARM64 (musl) | `@ff-labs/fff-bun-linux-arm64-musl` |
| Windows | x64 | `@ff-labs/fff-bun-win32-x64` |
| Windows | ARM64 | `@ff-labs/fff-bun-win32-arm64` |
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` |
| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` |
| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` |
| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` |
| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` |
| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` |
| Windows | x64 | `@ff-labs/fff-bin-win32-x64` |
| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` |
If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback.
@@ -113,7 +113,7 @@ Track file access for frecency scoring.
FileFinder.trackAccess("/path/to/file.ts");
```
### `FileFinder.liveGrep(query, options?)`
### `FileFinder.grep(query, options?)`
Search file contents with SIMD-accelerated matching.
@@ -129,7 +129,7 @@ interface GrepOptions {
}
// Plain text search
const result = FileFinder.liveGrep("TODO", { pageLimit: 20 });
const result = FileFinder.grep("TODO", { pageLimit: 20 });
if (result.ok) {
for (const match of result.value.items) {
console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
@@ -137,22 +137,22 @@ if (result.ok) {
}
// Regex search
const regexResult = FileFinder.liveGrep("fn\\s+\\w+", { mode: "regex" });
const regexResult = FileFinder.grep("fn\\s+\\w+", { mode: "regex" });
// Fuzzy search
const fuzzyResult = FileFinder.liveGrep("imprt recat", { mode: "fuzzy" });
const fuzzyResult = FileFinder.grep("imprt recat", { mode: "fuzzy" });
// Pagination
const page1 = FileFinder.liveGrep("error");
const page1 = FileFinder.grep("error");
if (page1.ok && page1.value.nextCursor) {
const page2 = FileFinder.liveGrep("error", {
const page2 = FileFinder.grep("error", {
cursor: page1.value.nextCursor,
});
}
// With file constraints
const tsOnly = FileFinder.liveGrep("*.ts useState");
const srcOnly = FileFinder.liveGrep("src/ handleClick");
const tsOnly = FileFinder.grep("*.ts useState");
const srcOnly = FileFinder.grep("src/ handleClick");
```
### `FileFinder.trackQuery(query, selectedFile)`
@@ -178,7 +178,7 @@ if (health.ok) {
### Other Methods
- `FileFinder.liveGrep(query, options?)` - Search file contents
- `FileFinder.grep(query, options?)` - Search file contents
- `FileFinder.scanFiles()` - Trigger rescan
- `FileFinder.isScanning()` - Check scan status
- `FileFinder.getScanProgress()` - Get scan progress
Binary file not shown.
+1 -1
View File
@@ -190,7 +190,7 @@ async function main() {
}
const searchStart = Date.now();
const result = finder.liveGrep(query, {
const result = finder.grep(query, {
mode: currentMode,
pageLimit: 30,
timeBudgetMs: 5000,
+8 -13
View File
@@ -13,19 +13,14 @@
}
},
"bin": {
"fff": "./scripts/cli.ts",
"fff-demo": "./examples/search.ts",
"fff-grep": "./examples/grep.ts"
},
"files": [
"src",
"bin",
"scripts",
"examples"
],
"scripts": {
"postinstall": "bun ./scripts/postinstall.ts",
"download": "bun ./scripts/cli.ts download",
"test": "bun test src/",
"typecheck": "tsc --noEmit",
"demo": "bun ./examples/search.ts"
@@ -67,14 +62,14 @@
},
"homepage": "https://github.com/dmtrKovalenko/fff.nvim#readme",
"optionalDependencies": {
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bun-win32-x64": "0.0.0",
"@ff-labs/fff-bun-win32-arm64": "0.0.0"
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bin-win32-x64": "0.0.0",
"@ff-labs/fff-bin-win32-arm64": "0.0.0"
},
"devDependencies": {
"@types/bun": "^1.3.8",
+20 -143
View File
@@ -1,25 +1,16 @@
/**
* Binary resolution utilities for fff
*
* Resolves the native binary from:
* 1. Platform-specific npm package (e.g. @ff-labs/fff-bun-darwin-arm64) - primary
* 2. Local bin/ directory (legacy or manual download)
* 3. Local dev build (target/release or target/debug)
* 4. GitHub releases (fallback, requires network)
* Resolves the native library from:
* 1. Platform-specific npm package (e.g. @ff-labs/fff-bin-darwin-arm64)
* 2. Local dev build (target/release or target/debug)
*/
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform";
const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
import { getLibFilename, getNpmPackageName } from "./platform";
/**
* Get the current file's directory
@@ -47,21 +38,6 @@ function getPackageDir(): string {
return dirname(currentDir);
}
/**
* Get the directory where binaries are stored (legacy/fallback)
*/
export function getBinDir(): string {
return join(getPackageDir(), "bin");
}
/**
* Get the full path to the native library in bin/ (legacy/fallback)
*/
export function getBinaryPath(): string {
const binDir = getBinDir();
return join(binDir, getLibFilename());
}
/**
* Check if the binary exists in any known location
*/
@@ -72,8 +48,8 @@ export function binaryExists(): boolean {
/**
* Try to resolve the binary from the platform-specific npm package.
*
* When users install @ff-labs/bun, npm/bun automatically installs the matching
* optionalDependency (e.g. @ff-labs/fff-bun-darwin-arm64). We resolve the binary
* When users install @ff-labs/fff-bun, npm/bun automatically installs the matching
* optionalDependency (e.g. @ff-labs/fff-bin-darwin-arm64). We resolve the binary
* path by requiring that package's package.json and looking for the binary
* in the same directory.
*/
@@ -101,7 +77,7 @@ function resolveFromNpmPackage(): string | null {
/**
* Get the development binary path (for local development)
*/
export function getDevBinaryPath(): string | null {
function getDevBinaryPath(): string | null {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
@@ -125,11 +101,20 @@ function isDevWorkspace(): boolean {
return existsSync(join(workspaceRoot, "Cargo.toml"));
}
/**
* Find the native library binary.
*
* Resolution order:
* - Dev workspace: local dev build first, then npm package
* - Production: npm package first, then dev build
*
* @returns Absolute path to the library, or null if not found
*/
export function findBinary(): string | null {
if (isDevWorkspace()) {
// 1. Local bin/ directory (populated by `make prepare-bun`)
const installedPath = getBinaryPath();
if (existsSync(installedPath)) return installedPath;
const binPath = join(getPackageDir(), "bin", getLibFilename());
if (existsSync(binPath)) return binPath;
// 2. Local dev build (target/release or target/debug)
const devPath = getDevBinaryPath();
@@ -143,117 +128,9 @@ export function findBinary(): string | null {
}
// Production: npm package first
// 1. Try platform-specific npm package first
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
// 2. Try local bin/ directory (legacy or manual download)
const installedPath = getBinaryPath();
if (existsSync(installedPath)) return installedPath;
// 3. Try local dev build
// Fallback: local dev build (e.g. user built from source)
return getDevBinaryPath();
}
/**
* Download the binary from GitHub releases as a fallback.
* This is only used when the platform npm package is not available.
*
* @param tag - The release tag to download (e.g. commit hash), or "latest"
*/
export async function downloadBinary(tag?: string): Promise<string> {
const resolvedTag = tag || "latest";
const triple = getTriple();
const ext = getLibExtension();
// Resolve "latest" tag via GitHub API
let releaseTag = resolvedTag;
if (releaseTag === "latest") {
console.log("fff: Fetching latest release tag from GitHub...");
releaseTag = await fetchLatestReleaseTag();
}
const binaryName = `c-lib-${triple}.${ext}`;
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${releaseTag}`;
const binaryUrl = `${baseUrl}/${binaryName}`;
console.log(`fff: Downloading native library for ${triple}...`);
console.log(`fff: Release: ${releaseTag}`);
const binaryResponse = await fetch(binaryUrl);
if (!binaryResponse.ok) {
throw new Error(
`Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}\nURL: ${binaryUrl}`,
);
}
const binaryBuffer = Buffer.from(await binaryResponse.arrayBuffer());
const binDir = getBinDir();
if (!existsSync(binDir)) {
mkdirSync(binDir, { recursive: true });
}
const binaryPath = getBinaryPath();
writeFileSync(binaryPath, binaryBuffer);
// Make executable on Unix
if (process.platform !== "win32") {
chmodSync(binaryPath, 0o755);
}
console.log(`fff: Binary downloaded to ${binaryPath}`);
return releaseTag;
}
/**
* Fetch the latest release tag from GitHub
*/
async function fetchLatestReleaseTag(): Promise<string> {
const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "fff-bun-client",
},
});
if (!response.ok) {
const allReleasesUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases`;
const allResponse = await fetch(allReleasesUrl, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "fff-bun-client",
},
});
if (!allResponse.ok) {
throw new Error(`Failed to fetch releases: ${allResponse.status}`);
}
const releases = (await allResponse.json()) as Array<{ tag_name: string }>;
if (releases.length === 0) {
throw new Error("No releases found");
}
return releases[0].tag_name;
}
const release = (await response.json()) as { tag_name: string };
return release.tag_name;
}
/**
* Ensure the binary exists, downloading from GitHub if necessary.
*/
export async function ensureBinary(): Promise<string> {
const existingPath = findBinary();
if (existingPath) {
return existingPath;
}
// Fallback: download from GitHub
await downloadBinary();
return getBinaryPath();
}
+630 -143
View File
@@ -9,15 +9,45 @@
*/
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";
import { findBinary } from "./download";
import type {
FileItem,
GrepMatch,
GrepResult,
Location,
Result,
Score,
SearchResult,
} from "./types";
import { createGrepCursor, err } from "./types";
/** Grep mode constants matching the C API (u8). */
const GREP_MODE_PLAIN = 0;
const GREP_MODE_REGEX = 1;
const GREP_MODE_FUZZY = 2;
/** Map string mode to u8 */
function grepModeToU8(mode?: string): number {
switch (mode) {
case "regex":
return GREP_MODE_REGEX;
case "fuzzy":
return GREP_MODE_FUZZY;
default:
return GREP_MODE_PLAIN;
}
}
// Define the FFI symbols
const ffiDefinition = {
// Lifecycle
fff_create: {
args: [FFIType.cstring],
fff_create_instance: {
args: [
FFIType.cstring, // base_path
FFIType.cstring, // frecency_db_path
FFIType.cstring, // history_db_path
FFIType.bool, // use_unsafe_no_lock
FFIType.bool, // warmup_mmap_cache
FFIType.bool, // ai_mode
],
returns: FFIType.ptr,
},
fff_destroy: {
@@ -27,19 +57,54 @@ const ffiDefinition = {
// Search
fff_search: {
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
args: [
FFIType.ptr, // handle
FFIType.cstring, // query
FFIType.cstring, // current_file
FFIType.u32, // max_threads
FFIType.u32, // page_index
FFIType.u32, // page_size
FFIType.i32, // combo_boost_multiplier
FFIType.u32, // min_combo_count
],
returns: FFIType.ptr,
},
// Live grep (content search)
fff_live_grep: {
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
args: [
FFIType.ptr, // handle
FFIType.cstring, // query
FFIType.u8, // mode
FFIType.u64, // max_file_size
FFIType.u32, // max_matches_per_file
FFIType.bool, // smart_case
FFIType.u32, // file_offset
FFIType.u32, // page_limit
FFIType.u64, // time_budget_ms
FFIType.u32, // before_context
FFIType.u32, // after_context
FFIType.bool, // classify_definitions
],
returns: FFIType.ptr,
},
// Multi-pattern grep (Aho-Corasick)
fff_multi_grep: {
args: [FFIType.ptr, FFIType.cstring],
args: [
FFIType.ptr, // handle
FFIType.cstring, // patterns_joined (\n-separated)
FFIType.cstring, // constraints
FFIType.u64, // max_file_size
FFIType.u32, // max_matches_per_file
FFIType.bool, // smart_case
FFIType.u32, // file_offset
FFIType.u32, // page_limit
FFIType.u64, // time_budget_ms
FFIType.u32, // before_context
FFIType.u32, // after_context
FFIType.bool, // classify_definitions
],
returns: FFIType.ptr,
},
@@ -87,6 +152,30 @@ const ffiDefinition = {
returns: FFIType.ptr,
},
// Search result accessors / free
fff_free_search_result: {
args: [FFIType.ptr],
returns: FFIType.void,
},
fff_search_result_get_item: {
args: [FFIType.ptr, FFIType.u32],
returns: FFIType.ptr,
},
fff_search_result_get_score: {
args: [FFIType.ptr, FFIType.u32],
returns: FFIType.ptr,
},
// Grep result accessors / free
fff_free_grep_result: {
args: [FFIType.ptr],
returns: FFIType.void,
},
fff_grep_result_get_match: {
args: [FFIType.ptr, FFIType.u32],
returns: FFIType.ptr,
},
// Memory management
fff_free_result: {
args: [FFIType.ptr],
@@ -96,6 +185,10 @@ const ffiDefinition = {
args: [FFIType.ptr],
returns: FFIType.void,
},
fff_free_scan_progress: {
args: [FFIType.ptr],
returns: FFIType.void,
},
} as const;
type FFFLibrary = ReturnType<typeof dlopen<typeof ffiDefinition>>;
@@ -112,7 +205,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. Build from source with `cargo build --release -p fff-c` or install the platform package.",
);
}
@@ -156,53 +249,92 @@ function snakeToCamel(obj: unknown): unknown {
return result;
}
// ---------------------------------------------------------------------------
// FffResult byte offsets (must match #[repr(C)] layout on 64-bit)
// { success: bool(1+7pad), error: *char(8), handle: *void(8), int_value: i64(8) }
// ---------------------------------------------------------------------------
const RES_SUCCESS = 0; // bool (1 + 7 padding)
const RES_ERROR = 8; // *mut c_char (8)
const RES_HANDLE = 16; // *mut c_void (8)
const RES_INT_VALUE = 24; // i64 (8)
/**
* Parse a FffResult from the FFI return value.
*
* The result is a pointer to a struct:
* { success: bool, data: *char, error: *char, handle: *void }
*
* Layout (with alignment padding):
* offset 0: success (bool, 1 byte + 7 padding)
* offset 8: data pointer (8 bytes)
* offset 16: error pointer (8 bytes)
* offset 24: handle pointer (8 bytes)
* Read the FffResult envelope: check success, extract payload, free envelope.
* On error returns a Result<never>. On success returns the raw handle pointer and int_value.
*/
function parseResult<T>(resultPtr: Pointer | null): Result<T> {
function readResultEnvelope(resultPtr: Pointer | null): { success: true; handlePtr: number; intValue: number } | Result<never> {
if (resultPtr === null) {
return err("FFI returned null pointer");
}
const success = read.u8(resultPtr, 0) !== 0;
const dataPtr = read.ptr(resultPtr, 8);
const errorPtr = read.ptr(resultPtr, 16);
const success = read.u8(resultPtr, RES_SUCCESS) !== 0;
const library = loadLibrary();
if (success) {
const data = readCString(dataPtr);
// Free the result
library.symbols.fff_free_result(resultPtr);
if (data === null || data === "") {
return { ok: true, value: undefined as T };
}
try {
const parsed = JSON.parse(data);
// Convert snake_case to camelCase for TypeScript consumers
const transformed = snakeToCamel(parsed) as T;
return { ok: true, value: transformed };
} catch {
// For simple values like "true" or numbers
return { ok: true, value: data as T };
}
} else {
if (!success) {
const errorPtr = read.ptr(resultPtr, RES_ERROR);
const errorMsg = readCString(errorPtr) || "Unknown error";
// Free the result
library.symbols.fff_free_result(resultPtr);
return err(errorMsg);
}
const handlePtr = read.ptr(resultPtr, RES_HANDLE);
const intValue = Number(read.i64(resultPtr, RES_INT_VALUE));
library.symbols.fff_free_result(resultPtr);
return { success: true, handlePtr, intValue };
}
/** Parse a FffResult that carries a bool in int_value (0 = false, nonzero = true). */
function parseBoolResult(resultPtr: Pointer | null): Result<boolean> {
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
return { ok: true, value: envelope.intValue !== 0 };
}
/** Parse a FffResult that carries an integer in int_value. */
function parseIntResult(resultPtr: Pointer | null): Result<number> {
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
return { ok: true, value: envelope.intValue };
}
/** Parse a FffResult that carries a string in handle (freed with fff_free_string). */
function parseStringResult(resultPtr: Pointer | null): Result<string | null> {
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
if (envelope.handlePtr === 0) return { ok: true, value: null };
const library = loadLibrary();
const str = readCString(envelope.handlePtr);
library.symbols.fff_free_string(asPtr(envelope.handlePtr));
return { ok: true, value: str };
}
/** Parse a FffResult that carries a JSON string in handle. */
function parseJsonResult<T>(resultPtr: Pointer | null): Result<T> {
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
if (envelope.handlePtr === 0) return { ok: true, value: undefined as T };
const library = loadLibrary();
const jsonStr = readCString(envelope.handlePtr);
library.symbols.fff_free_string(asPtr(envelope.handlePtr));
if (jsonStr === null || jsonStr === "") return { ok: true, value: undefined as T };
try {
return { ok: true, value: snakeToCamel(JSON.parse(jsonStr)) as T };
} catch {
return { ok: true, value: jsonStr as T };
}
}
/** Parse a FffResult with no payload (void, success/error only). */
function parseVoidResult(resultPtr: Pointer | null): Result<void> {
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
return { ok: true, value: undefined };
}
/**
@@ -212,28 +344,39 @@ export type NativeHandle = Pointer;
/**
* Create a new file finder instance.
*
* Returns the opaque native handle on success. The handle must be passed to
* all subsequent FFI calls and freed with `ffiDestroy`.
*/
export function ffiCreate(optsJson: string): Result<NativeHandle> {
export function ffiCreate(
basePath: string,
frecencyDbPath: string,
historyDbPath: string,
useUnsafeNoLock: boolean,
warmupMmapCache: boolean,
aiMode: boolean,
): Result<NativeHandle> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_create(ptr(encodeString(optsJson)));
const resultPtr = library.symbols.fff_create_instance(
ptr(encodeString(basePath)),
ptr(encodeString(frecencyDbPath)),
ptr(encodeString(historyDbPath)),
useUnsafeNoLock,
warmupMmapCache,
aiMode,
);
if (resultPtr === null) {
return err("FFI returned null pointer");
}
const success = read.u8(resultPtr, 0) !== 0;
const errorPtr = read.ptr(resultPtr, 16);
const handlePtr = read.ptr(resultPtr, 24);
const success = read.u8(resultPtr, RES_SUCCESS) !== 0;
const errorPtr = read.ptr(resultPtr, RES_ERROR);
const handlePtr = read.ptr(resultPtr, RES_HANDLE);
if (success) {
const handle = handlePtr as unknown as Pointer;
library.symbols.fff_free_result(resultPtr);
if (!handle || handle === (0 as unknown as Pointer)) {
return err("fff_create returned null handle");
return err("fff_create_instance returned null handle");
}
return { ok: true, value: handle };
@@ -252,21 +395,415 @@ export function ffiDestroy(handle: NativeHandle): void {
library.symbols.fff_destroy(handle);
}
// ---------------------------------------------------------------------------
// Struct byte offsets (must match #[repr(C)] layout on 64-bit)
// ---------------------------------------------------------------------------
// FffSearchResult { items: *mut, scores: *mut, count: u32, total_matched: u32, total_files: u32, location: FffLocation }
const SR_ITEMS = 0; // *mut FffFileItem (8)
const SR_SCORES = 8; // *mut FffScore (8)
const SR_COUNT = 16; // u32 (4)
const SR_MATCHED = 20; // u32 (4)
const SR_TOTAL = 24; // u32 (4)
// FffLocation is inlined at offset 28
const SR_LOC_TAG = 28; // u8 (1 + 3 padding)
const SR_LOC_LINE = 32; // i32 (4)
const SR_LOC_COL = 36; // i32 (4)
const SR_LOC_END_LINE = 40; // i32 (4)
const SR_LOC_END_COL = 44; // i32 (4)
// FffFileItem (80 bytes)
const FI_PATH = 0; // *mut c_char (8)
const FI_RELPATH = 8; // *mut c_char (8)
const FI_FNAME = 16; // *mut c_char (8)
const FI_GIT = 24; // *mut c_char (8)
const FI_SIZE = 32; // u64 (8)
const FI_MODIFIED = 40; // u64 (8)
const FI_ACCESS = 48; // i64 (8)
const FI_MODFR = 56; // i64 (8)
const FI_TOTAL_FR = 64; // i64 (8)
const _FI_BINARY = 72; // bool (1 + 7 pad)
const FI_SIZE_OF = 80;
// FffScore (48 bytes)
const SC_TOTAL = 0; // i32 (4)
const SC_BASE = 4; // i32 (4)
const SC_FNAME = 8; // i32 (4)
const SC_SPECIAL = 12; // i32 (4)
const SC_FREC = 16; // i32 (4)
const SC_DIST = 20; // i32 (4)
const SC_CURFILE = 24; // i32 (4)
const SC_COMBO = 28; // i32 (4)
const SC_EXACT = 32; // bool (1 + 7 pad)
const SC_MTYPE = 40; // *mut c_char (8)
const SC_SIZE_OF = 48;
/** Cast a number (raw address from pointer math) to Pointer for read.*. */
function asPtr(n: number): Pointer {
return n as unknown as Pointer;
}
/**
* Read an FffFileItem struct at the given raw address.
*/
function readFileItemStruct(p: number): FileItem {
const pp = asPtr(p);
return {
path: readCString(read.ptr(pp, FI_PATH)) ?? "",
relativePath: readCString(read.ptr(pp, FI_RELPATH)) ?? "",
fileName: readCString(read.ptr(pp, FI_FNAME)) ?? "",
gitStatus: readCString(read.ptr(pp, FI_GIT)) ?? "",
size: Number(read.u64(pp, FI_SIZE)),
modified: Number(read.u64(pp, FI_MODIFIED)),
accessFrecencyScore: Number(read.i64(pp, FI_ACCESS)),
modificationFrecencyScore: Number(read.i64(pp, FI_MODFR)),
totalFrecencyScore: Number(read.i64(pp, FI_TOTAL_FR)),
};
}
/**
* Read an FffScore struct at the given raw address.
*/
function readScoreStruct(p: number): Score {
const pp = asPtr(p);
return {
total: read.i32(pp, SC_TOTAL),
baseScore: read.i32(pp, SC_BASE),
filenameBonus: read.i32(pp, SC_FNAME),
specialFilenameBonus: read.i32(pp, SC_SPECIAL),
frecencyBoost: read.i32(pp, SC_FREC),
distancePenalty: read.i32(pp, SC_DIST),
currentFilePenalty: read.i32(pp, SC_CURFILE),
comboMatchBoost: read.i32(pp, SC_COMBO),
exactMatch: read.u8(pp, SC_EXACT) !== 0,
matchType: readCString(read.ptr(pp, SC_MTYPE)) ?? "",
};
}
/**
* Parse an FffSearchResult from a raw pointer, then free native memory.
*/
function parseSearchResult(resultPtr: Pointer | null): Result<SearchResult> {
if (resultPtr === null) {
return err("FFI returned null pointer");
}
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
if (envelope.handlePtr === 0) {
return err("fff_search returned null search result");
}
const hp = asPtr(envelope.handlePtr);
const count = read.u32(hp, SR_COUNT);
const totalMatched = read.u32(hp, SR_MATCHED);
const totalFiles = read.u32(hp, SR_TOTAL);
const itemsBase = read.ptr(hp, SR_ITEMS);
const scoresBase = read.ptr(hp, SR_SCORES);
// Read location
const locTag = read.u8(hp, SR_LOC_TAG);
let location: Location | undefined;
if (locTag === 1) {
location = { type: "line", line: read.i32(hp, SR_LOC_LINE) };
} else if (locTag === 2) {
location = {
type: "position",
line: read.i32(hp, SR_LOC_LINE),
col: read.i32(hp, SR_LOC_COL),
};
} else if (locTag === 3) {
location = {
type: "range",
start: { line: read.i32(hp, SR_LOC_LINE), col: read.i32(hp, SR_LOC_COL) },
end: {
line: read.i32(hp, SR_LOC_END_LINE),
col: read.i32(hp, SR_LOC_END_COL),
},
};
}
// Read items and scores arrays using pointer arithmetic
const items: FileItem[] = [];
const scores: Score[] = [];
for (let i = 0; i < count; i++) {
items.push(readFileItemStruct(itemsBase + i * FI_SIZE_OF));
scores.push(readScoreStruct(scoresBase + i * SC_SIZE_OF));
}
// Free native search result
loadLibrary().symbols.fff_free_search_result(hp);
const result: SearchResult = { items, scores, totalMatched, totalFiles };
if (location) {
result.location = location;
}
return { ok: true, value: result };
}
// ---------------------------------------------------------------------------
// FffGrepMatch byte offsets (must match #[repr(C)] layout on 64-bit)
// ---------------------------------------------------------------------------
// Pointers (8 bytes each)
const GM_PATH = 0;
const GM_RELPATH = 8;
const GM_FNAME = 16;
const GM_GIT = 24;
const GM_LINE_CONTENT = 32;
const GM_MATCH_RANGES = 40;
const GM_CTX_BEFORE = 48;
const GM_CTX_AFTER = 56;
// 8-byte numeric fields
const GM_SIZE = 64;
const GM_MODIFIED = 72;
const GM_TOTAL_FR = 80;
const GM_ACCESS_FR = 88;
const GM_MOD_FR = 96;
const GM_LINE_NUM = 104;
const GM_BYTE_OFF = 112;
// 4-byte fields
const GM_COL = 120;
const GM_MR_COUNT = 124;
const GM_CTX_B_COUNT = 128;
const GM_CTX_A_COUNT = 132;
// 2-byte
const GM_FUZZY_SCORE = 136;
// 1-byte
const GM_HAS_FUZZY = 138;
const GM_IS_BINARY = 139;
const _GM_IS_DEF = 140;
// struct size: pad to 8-byte alignment → 144
const GM_SIZE_OF = 144;
// FffGrepResult
const GR_ITEMS = 0; // *mut FffGrepMatch (8)
const GR_COUNT = 8; // u32 (4)
const GR_MATCHED = 12; // u32 (4)
const GR_FILES_SEARCHED = 16; // u32 (4)
const GR_TOTAL_FILES = 20; // u32 (4)
const GR_FILTERED = 24; // u32 (4)
const GR_NEXT_OFFSET = 28; // u32 (4)
const GR_REGEX_ERR = 32; // *mut c_char (8)
// FffMatchRange (8 bytes)
const MR_START = 0;
const MR_END = 4;
const MR_SIZE = 8;
/**
* Read a C string array (char**) at the given pointer, with `count` elements.
*/
function readCStringArray(base: number, count: number): string[] {
if (count === 0 || base === 0) return [];
const result: string[] = [];
const bp = asPtr(base);
for (let i = 0; i < count; i++) {
const strPtr = read.ptr(bp, i * 8); // each pointer is 8 bytes
result.push(readCString(strPtr) ?? "");
}
return result;
}
/**
* Read an FffGrepMatch struct at the given raw address.
*/
function readGrepMatchStruct(p: number): GrepMatch {
const pp = asPtr(p);
const matchRangesPtr = read.ptr(pp, GM_MATCH_RANGES);
const matchRangesCount = read.u32(pp, GM_MR_COUNT);
const matchRanges: [number, number][] = [];
for (let i = 0; i < matchRangesCount; i++) {
const base = matchRangesPtr + i * MR_SIZE;
const bp = asPtr(base);
matchRanges.push([read.u32(bp, MR_START), read.u32(bp, MR_END)]);
}
const hasFuzzy = read.u8(pp, GM_HAS_FUZZY) !== 0;
const ctxBeforeCount = read.u32(pp, GM_CTX_B_COUNT);
const ctxAfterCount = read.u32(pp, GM_CTX_A_COUNT);
const match: GrepMatch = {
path: readCString(read.ptr(pp, GM_PATH)) ?? "",
relativePath: readCString(read.ptr(pp, GM_RELPATH)) ?? "",
fileName: readCString(read.ptr(pp, GM_FNAME)) ?? "",
gitStatus: readCString(read.ptr(pp, GM_GIT)) ?? "",
lineContent: readCString(read.ptr(pp, GM_LINE_CONTENT)) ?? "",
size: Number(read.u64(pp, GM_SIZE)),
modified: Number(read.u64(pp, GM_MODIFIED)),
totalFrecencyScore: Number(read.i64(pp, GM_TOTAL_FR)),
accessFrecencyScore: Number(read.i64(pp, GM_ACCESS_FR)),
modificationFrecencyScore: Number(read.i64(pp, GM_MOD_FR)),
isBinary: read.u8(pp, GM_IS_BINARY) !== 0,
lineNumber: Number(read.u64(pp, GM_LINE_NUM)),
col: read.u32(pp, GM_COL),
byteOffset: Number(read.u64(pp, GM_BYTE_OFF)),
matchRanges,
};
if (hasFuzzy) {
match.fuzzyScore = read.u16(pp, GM_FUZZY_SCORE);
}
if (ctxBeforeCount > 0) {
match.contextBefore = readCStringArray(
read.ptr(pp, GM_CTX_BEFORE),
ctxBeforeCount,
);
}
if (ctxAfterCount > 0) {
match.contextAfter = readCStringArray(
read.ptr(pp, GM_CTX_AFTER),
ctxAfterCount,
);
}
return match;
}
/**
* Parse an FffGrepResult from a raw FffResult pointer, then free native memory.
*/
function parseGrepResult(resultPtr: Pointer | null): Result<GrepResult> {
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
if (envelope.handlePtr === 0) {
return err("grep returned null result");
}
const hp = asPtr(envelope.handlePtr);
const count = read.u32(hp, GR_COUNT);
const totalMatched = read.u32(hp, GR_MATCHED);
const totalFilesSearched = read.u32(hp, GR_FILES_SEARCHED);
const totalFiles = read.u32(hp, GR_TOTAL_FILES);
const filteredFileCount = read.u32(hp, GR_FILTERED);
const nextFileOffset = read.u32(hp, GR_NEXT_OFFSET);
const regexErrPtr = read.ptr(hp, GR_REGEX_ERR);
const regexFallbackError = readCString(regexErrPtr) ?? undefined;
const itemsBase = read.ptr(hp, GR_ITEMS);
const items: GrepMatch[] = [];
for (let i = 0; i < count; i++) {
items.push(readGrepMatchStruct(itemsBase + i * GM_SIZE_OF));
}
loadLibrary().symbols.fff_free_grep_result(hp);
const grepResult: GrepResult = {
items,
totalMatched,
totalFilesSearched,
totalFiles,
filteredFileCount,
nextCursor: nextFileOffset > 0 ? createGrepCursor(nextFileOffset) : null,
};
if (regexFallbackError) {
grepResult.regexFallbackError = regexFallbackError;
}
return { ok: true, value: grepResult };
}
/**
* Perform fuzzy search.
*/
export function ffiSearch(
handle: NativeHandle,
query: string,
optsJson: string,
): Result<unknown> {
currentFile: string,
maxThreads: number,
pageIndex: number,
pageSize: number,
comboBoostMultiplier: number,
minComboCount: number,
): Result<SearchResult> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_search(
handle,
ptr(encodeString(query)),
ptr(encodeString(optsJson)),
ptr(encodeString(currentFile)),
maxThreads,
pageIndex,
pageSize,
comboBoostMultiplier,
minComboCount,
);
return parseResult<unknown>(resultPtr);
return parseSearchResult(resultPtr);
}
/**
* Live grep - search file contents.
*/
export function ffiLiveGrep(
handle: NativeHandle,
query: string,
mode: string,
maxFileSize: number,
maxMatchesPerFile: number,
smartCase: boolean,
fileOffset: number,
pageLimit: number,
timeBudgetMs: number,
beforeContext: number,
afterContext: number,
classifyDefinitions: boolean,
): Result<GrepResult> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_live_grep(
handle,
ptr(encodeString(query)),
grepModeToU8(mode),
BigInt(maxFileSize),
maxMatchesPerFile,
smartCase,
fileOffset,
pageLimit,
BigInt(timeBudgetMs),
beforeContext,
afterContext,
classifyDefinitions,
);
return parseGrepResult(resultPtr);
}
/**
* Multi-pattern grep - Aho-Corasick multi-needle search.
*/
export function ffiMultiGrep(
handle: NativeHandle,
patternsJoined: string,
constraints: string,
maxFileSize: number,
maxMatchesPerFile: number,
smartCase: boolean,
fileOffset: number,
pageLimit: number,
timeBudgetMs: number,
beforeContext: number,
afterContext: number,
classifyDefinitions: boolean,
): Result<GrepResult> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_multi_grep(
handle,
ptr(encodeString(patternsJoined)),
ptr(encodeString(constraints)),
BigInt(maxFileSize),
maxMatchesPerFile,
smartCase,
fileOffset,
pageLimit,
BigInt(timeBudgetMs),
beforeContext,
afterContext,
classifyDefinitions,
);
return parseGrepResult(resultPtr);
}
/**
@@ -275,7 +812,7 @@ export function ffiSearch(
export function ffiScanFiles(handle: NativeHandle): Result<void> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_scan_files(handle);
return parseResult<void>(resultPtr);
return parseVoidResult(resultPtr);
}
/**
@@ -286,13 +823,30 @@ export function ffiIsScanning(handle: NativeHandle): boolean {
return library.symbols.fff_is_scanning(handle) as boolean;
}
// FffScanProgress { scanned_files_count: u64(8), is_scanning: bool(1+7pad) }
const SP_COUNT = 0; // u64 (8)
const SP_SCANNING = 8; // bool (1 + 7 pad)
/**
* Get scan progress.
*/
export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
export function ffiGetScanProgress(handle: NativeHandle): Result<{ scannedFilesCount: number; isScanning: boolean }> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_get_scan_progress(handle);
return parseResult<unknown>(resultPtr);
const envelope = readResultEnvelope(resultPtr);
if (!("success" in envelope)) return envelope;
if (envelope.handlePtr === 0) {
return err("scan progress returned null");
}
const hp = asPtr(envelope.handlePtr);
const result = {
scannedFilesCount: Number(read.u64(hp, SP_COUNT)),
isScanning: read.u8(hp, SP_SCANNING) !== 0,
};
library.symbols.fff_free_scan_progress(hp);
return { ok: true, value: result };
}
/**
@@ -303,15 +857,8 @@ export function ffiWaitForScan(
timeoutMs: number,
): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_wait_for_scan(
handle,
BigInt(timeoutMs),
);
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
// JSON.parse("true") returns boolean true, but we also handle
// the string case defensively.
return { ok: true, value: result.value === true || result.value === "true" };
const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs));
return parseBoolResult(resultPtr);
}
/**
@@ -322,11 +869,8 @@ export function ffiRestartIndex(
newPath: string,
): Result<void> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_restart_index(
handle,
ptr(encodeString(newPath)),
);
return parseResult<void>(resultPtr);
const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath)));
return parseVoidResult(resultPtr);
}
/**
@@ -335,16 +879,7 @@ export function ffiRestartIndex(
export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_refresh_git_status(handle);
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 parseIntResult(resultPtr);
}
/**
@@ -361,9 +896,7 @@ export function ffiTrackQuery(
ptr(encodeString(query)),
ptr(encodeString(filePath)),
);
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
return { ok: true, value: result.value === true || result.value === "true" };
return parseBoolResult(resultPtr);
}
/**
@@ -374,15 +907,8 @@ export function ffiGetHistoricalQuery(
offset: number,
): Result<string | null> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_get_historical_query(
handle,
BigInt(offset),
);
const result = parseResult<string | null>(resultPtr);
if (!result.ok) return result;
if (result.value === null || result.value === "null")
return { ok: true, value: null };
return result as Result<string>;
const resultPtr = library.symbols.fff_get_historical_query(handle, BigInt(offset));
return parseStringResult(resultPtr);
}
/**
@@ -399,55 +925,16 @@ export function ffiHealthCheck(
handle ?? (0 as unknown as Pointer),
ptr(encodeString(testPath)),
);
return parseResult<unknown>(resultPtr);
return parseJsonResult<unknown>(resultPtr);
}
/**
* Detect workspace roots in the indexed directory.
* Ensure the library is loaded.
*
* Loads the native library from the platform-specific npm package
* or a local dev build. Throws if the binary is not found.
*/
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,
): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_live_grep(
handle,
ptr(encodeString(query)),
ptr(encodeString(optsJson)),
);
return parseResult<unknown>(resultPtr);
}
/**
* Multi-pattern grep - Aho-Corasick multi-needle search.
*/
export function ffiMultiGrep(
handle: NativeHandle,
optsJson: string,
): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_multi_grep(
handle,
ptr(encodeString(optsJson)),
);
return parseResult<unknown>(resultPtr);
}
/**
* Ensure the library is loaded (for preloading).
*/
export async function ensureLoaded(): Promise<void> {
await ensureBinary();
export function ensureLoaded(): void {
loadLibrary();
}
+55 -57
View File
@@ -40,33 +40,7 @@ import type {
SearchResult,
} from "./types";
import {
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 };
}
import { err } from "./types";
/**
* FileFinder - Fast file finder with fuzzy search
@@ -127,8 +101,14 @@ export class FileFinder {
* ```
*/
static create(options: InitOptions): Result<FileFinder> {
const internalOpts = toInternalInitOptions(options);
const result = ffiCreate(JSON.stringify(internalOpts));
const result = ffiCreate(
options.basePath,
options.frecencyDbPath ?? "",
options.historyDbPath ?? "",
options.useUnsafeNoLock ?? false,
options.warmupMmapCache ?? false,
options.aiMode ?? false,
);
if (!result.ok) {
return result;
@@ -192,18 +172,20 @@ export class FileFinder {
* }
* ```
*/
search(query: string, options?: SearchOptions): Result<SearchResult> {
fileSearch(query: string, options?: SearchOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const internalOpts = toInternalSearchOptions(options);
const result = ffiSearch(guard.value, query, JSON.stringify(internalOpts));
if (!result.ok) {
return result;
}
return result as Result<SearchResult>;
return ffiSearch(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
@@ -228,28 +210,38 @@ export class FileFinder {
* @example
* ```typescript
* // First page
* const result = finder.liveGrep("TODO", { mode: "plain" });
* const result = finder.grep("TODO", { mode: "plain" });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* // Fetch next page
* if (result.value.nextCursor) {
* const page2 = finder.liveGrep("TODO", {
* const page2 = finder.grep("TODO", {
* cursor: result.value.nextCursor,
* });
* }
* }
* ```
*/
liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
grep(query: string, options?: GrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const internalOpts = toInternalGrepOptions(options);
const result = ffiLiveGrep(guard.value, query, JSON.stringify(internalOpts));
return transformGrepResult(result);
return ffiLiveGrep(
guard.value,
query,
options?.mode ?? "plain",
options?.maxFileSize ?? 0,
options?.maxMatchesPerFile ?? 0,
options?.smartCase ?? true,
options?.cursor?._offset ?? 0,
0, // page_limit (0 = default 50)
options?.timeBudgetMs ?? 0,
options?.beforeContext ?? 0,
options?.afterContext ?? 0,
false,
);
}
/**
@@ -285,10 +277,20 @@ export class FileFinder {
return err("patterns array must have at least 1 element");
}
const internalOpts = toInternalMultiGrepOptions(options);
const result = ffiMultiGrep(guard.value, JSON.stringify(internalOpts));
return transformGrepResult(result);
return ffiMultiGrep(
guard.value,
options.patterns.join("\n"),
options.constraints ?? "",
options.maxFileSize ?? 0,
options.maxMatchesPerFile ?? 0,
options.smartCase ?? true,
options.cursor?._offset ?? 0,
0, // page_limit (0 = default 50)
options.timeBudgetMs ?? 0,
options.beforeContext ?? 0,
options.afterContext ?? 0,
false,
);
}
/**
@@ -412,14 +414,9 @@ export class FileFinder {
return isAvailable();
}
/**
* Ensure the native library is loaded.
*
* This will download the binary if needed and load it.
* Useful for preloading before first use.
*/
static async ensureLoaded(): Promise<void> {
return ensureLoaded();
/** Ensure the native library is loaded. */
static ensureLoaded(): void {
ensureLoaded();
}
/**
@@ -433,3 +430,4 @@ export class FileFinder {
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
}
}
+7 -7
View File
@@ -51,7 +51,7 @@ function sleep(ms: number) {
}
function findFile(finder: FileFinder, name: string): FileItem | undefined {
const result = finder.search(name, { pageSize: 200 });
const result = finder.fileSearch(name, { pageSize: 200 });
if (!result.ok) throw new Error(`search failed: ${result.error}`);
return result.value.items.find((item) => item.fileName === name);
}
@@ -99,15 +99,15 @@ async function waitForFileGone(finder: FileFinder, name: string): Promise<boolea
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 });
const result = finder.fileSearch("", { pageSize: 200 });
if (result.ok && result.value.totalFiles === count) return count;
await sleep(POLL_INTERVAL_MS);
}
const result = finder.search("", { pageSize: 200 });
const result = finder.fileSearch("", { pageSize: 200 });
return result.ok ? result.value.totalFiles : -1;
}
/** Poll liveGrep until predicate on totalMatched is satisfied, or the timeout is exceeded. */
/** Poll grep until predicate on totalMatched is satisfied, or the timeout is exceeded. */
async function waitForGrep(
finder: FileFinder,
pattern: string,
@@ -116,11 +116,11 @@ async function waitForGrep(
) {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const result = finder.liveGrep(pattern, options);
const result = finder.grep(pattern, options);
if (result.ok && predicate(result.value.totalMatched)) return result;
await sleep(POLL_INTERVAL_MS);
}
return finder.liveGrep(pattern, options);
return finder.grep(pattern, options);
}
describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => {
@@ -161,7 +161,7 @@ describe.skipIf(process.platform === "win32")("Git lifecycle integration", () =>
});
test("initial scan indexes all committed files", () => {
const result = finder.search("", { pageSize: 200 });
const result = finder.fileSearch("", { pageSize: 200 });
expect(result.ok).toBe(true);
if (!result.ok) return;
+13 -21
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { findBinary, getDevBinaryPath } from "./download";
import { findBinary } from "./download";
import { FileFinder } from "./index";
import { getLibExtension, getLibFilename, getTriple } from "./platform";
@@ -46,14 +46,6 @@ describe("Platform Detection", () => {
});
describe("Binary Detection", () => {
test("getDevBinaryPath finds local build", () => {
const devPath = getDevBinaryPath();
expect(devPath).not.toBeNull();
// Normalize path for cross-platform comparison (Windows uses backslashes)
const normalizedPath = normalizePath(devPath);
expect(normalizedPath).toContain("target/release");
});
test("findBinary returns a path", () => {
const path = findBinary();
expect(path).not.toBeNull();
@@ -120,7 +112,7 @@ describe("FileFinder - Full Lifecycle", () => {
if (progress.ok) {
}
const result = finder.search("");
const result = finder.fileSearch("");
expect(result.ok).toBe(true);
if (result.ok) {
@@ -140,7 +132,7 @@ describe("FileFinder - Full Lifecycle", () => {
});
test("search returns a valid result structure", () => {
const result = finder.search("Cargo.toml");
const result = finder.fileSearch("Cargo.toml");
expect(result.ok).toBe(true);
if (result.ok) {
@@ -152,7 +144,7 @@ describe("FileFinder - Full Lifecycle", () => {
});
test("search returns empty for non-matching query", () => {
const result = finder.search("xyznonexistentfilenamexyz123456");
const result = finder.fileSearch("xyznonexistentfilenamexyz123456");
expect(result.ok).toBe(true);
if (result.ok) {
@@ -162,7 +154,7 @@ describe("FileFinder - Full Lifecycle", () => {
});
test("search respects pageSize option", () => {
const result = finder.search("ts", { pageSize: 3 });
const result = finder.fileSearch("ts", { pageSize: 3 });
expect(result.ok).toBe(true);
if (result.ok) {
@@ -170,8 +162,8 @@ describe("FileFinder - Full Lifecycle", () => {
}
});
test("liveGrep plain text returns matching lines", () => {
const result = finder.liveGrep("fff-core", {
test("grep plain text returns matching lines", () => {
const result = finder.grep("fff-core", {
mode: "plain",
});
expect(result.ok).toBe(true);
@@ -205,9 +197,9 @@ describe("FileFinder - Full Lifecycle", () => {
}
});
test("liveGrep fuzzy mode returns results with scores", () => {
test("grep fuzzy mode returns results with scores", () => {
// Intentional typo: "depdnency" instead of "dependency" to exercise fuzzy matching
const result = finder.liveGrep("depdnency", {
const result = finder.grep("depdnency", {
mode: "fuzzy",
});
expect(result.ok).toBe(true);
@@ -273,8 +265,8 @@ describe("FileFinder - Full Lifecycle", () => {
const finder2 = result2.value;
// Both should work independently
const search1 = finder.search("Cargo");
const search2 = finder2.search("Cargo");
const search1 = finder.fileSearch("Cargo");
const search2 = finder2.fileSearch("Cargo");
expect(search1.ok).toBe(true);
expect(search2.ok).toBe(true);
@@ -282,7 +274,7 @@ describe("FileFinder - Full Lifecycle", () => {
// Destroying one should not affect the other
finder2.destroy();
const search3 = finder.search("Cargo");
const search3 = finder.fileSearch("Cargo");
expect(search3.ok).toBe(true);
}
});
@@ -297,7 +289,7 @@ describe("FileFinder - Error Handling", () => {
const f = createResult.value;
f.destroy();
const result = f.search("test");
const result = f.fileSearch("test");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("destroyed");
+2 -6
View File
@@ -23,7 +23,7 @@
* finder.waitForScan(5000);
*
* // Search for files
* const search = finder.search("main.ts");
* const search = finder.fileSearch("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
@@ -37,15 +37,11 @@
* @packageDocumentation
*/
export { FileFinder } from "./finder";
export {
binaryExists,
downloadBinary,
ensureBinary,
findBinary,
getBinaryPath,
} from "./download";
export { FileFinder } from "./finder";
export {
getLibExtension,
+9 -9
View File
@@ -95,20 +95,20 @@ export function getLibFilename(): string {
* Map from Rust target triple to npm platform package name
*/
const TRIPLE_TO_NPM_PACKAGE: Record<string, string> = {
"aarch64-apple-darwin": "@ff-labs/fff-bun-darwin-arm64",
"x86_64-apple-darwin": "@ff-labs/fff-bun-darwin-x64",
"x86_64-unknown-linux-gnu": "@ff-labs/fff-bun-linux-x64-gnu",
"aarch64-unknown-linux-gnu": "@ff-labs/fff-bun-linux-arm64-gnu",
"x86_64-unknown-linux-musl": "@ff-labs/fff-bun-linux-x64-musl",
"aarch64-unknown-linux-musl": "@ff-labs/fff-bun-linux-arm64-musl",
"x86_64-pc-windows-msvc": "@ff-labs/fff-bun-win32-x64",
"aarch64-pc-windows-msvc": "@ff-labs/fff-bun-win32-arm64",
"aarch64-apple-darwin": "@ff-labs/fff-bin-darwin-arm64",
"x86_64-apple-darwin": "@ff-labs/fff-bin-darwin-x64",
"x86_64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-x64-gnu",
"aarch64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-arm64-gnu",
"x86_64-unknown-linux-musl": "@ff-labs/fff-bin-linux-x64-musl",
"aarch64-unknown-linux-musl": "@ff-labs/fff-bin-linux-arm64-musl",
"x86_64-pc-windows-msvc": "@ff-labs/fff-bin-win32-x64",
"aarch64-pc-windows-msvc": "@ff-labs/fff-bin-win32-arm64",
};
/**
* Get the npm package name for the current platform's native binary.
*
* @returns Package name like "@ff-labs/fff-bun-darwin-arm64"
* @returns Package name like "@ff-labs/fff-bin-darwin-arm64"
* @throws If the current platform is not supported
*/
export function getNpmPackageName(): string {
-131
View File
@@ -208,62 +208,6 @@ export interface HealthCheck {
};
}
/**
* Internal: Options format sent to Rust FFI
* @internal
*/
export interface InitOptionsInternal {
base_path: string;
frecency_db_path?: string;
history_db_path?: string;
use_unsafe_no_lock: boolean;
warmup_mmap_cache: boolean;
ai_mode: boolean;
}
/**
* Internal: Search options format sent to Rust FFI
* @internal
*/
export interface SearchOptionsInternal {
max_threads?: number;
current_file?: string;
combo_boost_multiplier?: number;
min_combo_count?: number;
page_index?: number;
page_size?: number;
}
/**
* Convert public InitOptions to internal format
* @internal
*/
export function toInternalInitOptions(opts: InitOptions): InitOptionsInternal {
return {
base_path: opts.basePath,
frecency_db_path: opts.frecencyDbPath,
history_db_path: opts.historyDbPath,
use_unsafe_no_lock: opts.useUnsafeNoLock ?? false,
warmup_mmap_cache: opts.warmupMmapCache ?? false,
ai_mode: opts.aiMode ?? false,
};
}
/**
* Convert public SearchOptions to internal format
* @internal
*/
export function toInternalSearchOptions(opts?: SearchOptions): SearchOptionsInternal {
return {
max_threads: opts?.maxThreads,
current_file: opts?.currentFile,
combo_boost_multiplier: opts?.comboBoostMultiplier,
min_combo_count: opts?.minComboCount,
page_index: opts?.pageIndex,
page_size: opts?.pageSize,
};
}
/**
* Grep search mode
*/
@@ -417,78 +361,3 @@ export interface MultiGrepOptions {
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
*/
export interface GrepOptionsInternal {
max_file_size?: number;
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,
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,
};
}
+4 -4
View File
@@ -74,7 +74,7 @@ async function main() {
// Search test
console.log("Searching for 'lib.rs'...");
const searchResult = finder.search("lib.rs", { pageSize: 5 });
const searchResult = finder.fileSearch("lib.rs", { pageSize: 5 });
if (searchResult.ok) {
console.log(`Found ${searchResult.value.totalMatched} matches (showing first 5):\n`);
@@ -94,7 +94,7 @@ async function main() {
// Search with different query
console.log("Searching for 'package.json'...");
const searchResult2 = finder.search("package.json", { pageSize: 3 });
const searchResult2 = finder.fileSearch("package.json", { pageSize: 3 });
if (searchResult2.ok) {
console.log(`Found ${searchResult2.value.totalMatched} matches:\n`);
@@ -127,7 +127,7 @@ async function main() {
console.log(" Second instance created successfully");
finder2.waitForScan(5000);
const search2 = finder2.search("Cargo.toml");
const search2 = finder2.fileSearch("Cargo.toml");
if (search2.ok) {
console.log(
` Second instance found ${search2.value.totalMatched} matches for 'Cargo.toml'`,
@@ -138,7 +138,7 @@ async function main() {
console.log(" Second instance destroyed");
// First instance should still work
const search3 = finder.search("Cargo.toml");
const search3 = finder.fileSearch("Cargo.toml");
if (search3.ok) {
console.log(` First instance still works: ${search3.value.totalMatched} matches`);
}
+1 -1
View File
@@ -14,6 +14,6 @@
"lib": ["ESNext"],
"types": ["bun"]
},
"include": ["src/**/*", "scripts/**/*", "*.ts"],
"include": ["src/**/*", "*.ts"],
"exclude": ["node_modules", "dist", "bin"]
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"private": false,
"description": "High-performance fuzzy file finder for Node.js - perfect for LLM agent tools",
"type": "module",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
".": {
"import": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node test/e2e.mjs",
"typecheck": "tsc --noEmit"
},
"engines": {
"node": ">=18.0.0"
},
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"x64",
"arm64"
],
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"directory": "packages/fff-node"
},
"keywords": [
"file-finder",
"fuzzy-search",
"node",
"ffi",
"llm-tools",
"agent-tools",
"fast",
"rust"
],
"author": "Dmitry Kovalenko",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/dmtrKovalenko/fff.nvim/issues"
},
"homepage": "https://github.com/dmtrKovalenko/fff.nvim#readme",
"dependencies": {
"ffi-rs": "^1.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bin-win32-x64": "0.0.0",
"@ff-labs/fff-bin-win32-arm64": "0.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^22.0.0"
}
}
+30 -24
View File
@@ -1,21 +1,21 @@
#!/usr/bin/env bun
/**
* CLI tool for fff package management
* CLI tool for fff-node package management
*
* Usage:
* bunx fff download [tag] - Download native binary from GitHub
* bunx fff info - Show platform and binary info
* npx @ff-labs/fff-node download [tag] - Download native binary from GitHub
* npx @ff-labs/fff-node info - Show platform and binary info
*/
import { downloadBinary, getBinaryPath, findBinary } from "../src/download";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { downloadBinary, findBinary, getBinaryPath } from "../src/binary.js";
import {
getTriple,
getLibExtension,
getLibFilename,
getNpmPackageName,
} from "../src/platform";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
getTriple,
} from "../src/platform.js";
const args = process.argv.slice(2);
const command = args[0];
@@ -24,12 +24,12 @@ interface PackageJson {
version: string;
}
async function getPackageInfo(): Promise<PackageJson> {
function getPackageInfo(): PackageJson {
const currentDir = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(currentDir, "..", "package.json");
try {
return await Bun.file(packageJsonPath).json();
return JSON.parse(readFileSync(packageJsonPath, "utf-8"));
} catch {
return { version: "unknown" };
}
@@ -51,7 +51,7 @@ async function main() {
}
case "info": {
const pkg = await getPackageInfo();
const pkg = getPackageInfo();
let npmPackage: string;
try {
npmPackage = getNpmPackageName();
@@ -59,7 +59,7 @@ async function main() {
npmPackage = "unsupported";
}
console.log("fff - Fast File Finder");
console.log("fff - Fast File Finder (Node.js)");
console.log(`Package version: ${pkg.version}`);
console.log("");
console.log("Platform Information:");
@@ -73,9 +73,9 @@ async function main() {
if (existing) {
console.log(` Found: ${existing}`);
} else {
console.log(` Not found`);
console.log(" Not found");
console.log(` Expected path: ${getBinaryPath()}`);
console.log(` Try: bun add ${npmPackage}`);
console.log(` Try: npm add ${npmPackage}`);
}
break;
}
@@ -83,26 +83,32 @@ async function main() {
case "version":
case "--version":
case "-v": {
const pkg = await getPackageInfo();
const pkg = getPackageInfo();
console.log(pkg.version);
break;
}
default: {
const pkg = await getPackageInfo();
console.log(`fff - Fast File Finder CLI v${pkg.version}`);
const pkg = getPackageInfo();
console.log(`fff - Fast File Finder CLI (Node.js) v${pkg.version}`);
console.log("");
console.log("Usage:");
console.log(
" bunx fff download [tag] Download native binary from GitHub (fallback)",
" npx @ff-labs/fff-node 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");
console.log(
" npx @ff-labs/fff-node info Show platform and binary info",
);
console.log(" npx @ff-labs/fff-node version Show version");
console.log(" npx @ff-labs/fff-node help Show this help message");
console.log("");
console.log("Examples:");
console.log(" bunx fff download Download latest binary from GitHub");
console.log(" bunx fff download abc1234 Download specific release tag");
console.log(
" npx @ff-labs/fff-node download Download latest binary from GitHub",
);
console.log(
" npx @ff-labs/fff-node download abc1234 Download specific release tag",
);
console.log("");
console.log(
"Note: Binaries are normally provided via platform-specific npm packages.",
@@ -1,4 +1,3 @@
#!/usr/bin/env bun
/**
* Postinstall script - ensures the native binary is available
*
@@ -8,8 +7,8 @@
* 3. Fallback: download from GitHub releases
*/
import { findBinary, downloadBinary } from "../src/download";
import { getNpmPackageName } from "../src/platform";
import { downloadBinary, findBinary } from "../src/binary.js";
import { getNpmPackageName } from "../src/platform.js";
async function main() {
// Check if binary is already available (npm package or dev build)
@@ -40,7 +39,9 @@ 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 `npx @ff-labs/fff-node 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
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Binary resolution utilities for fff-node
*
* Resolves the native library from:
* 1. Platform-specific npm package (e.g. @ff-labs/fff-bin-darwin-arm64)
* 2. Local dev build (target/release or target/debug)
*/
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getLibFilename, getNpmPackageName } from "./platform.js";
/**
* Get the current file's directory
*/
function getCurrentDir(): string {
const url = import.meta.url;
if (url.startsWith("file://")) {
return dirname(fileURLToPath(url));
}
return dirname(url);
}
/**
* Get the package root directory
*/
function getPackageDir(): string {
const currentDir = getCurrentDir();
// In dev: src/ -> package root
// In dist: dist/src/ -> package root
// We look for package.json to find the actual root
let dir = currentDir;
for (let i = 0; i < 5; i++) {
if (existsSync(join(dir, "package.json"))) {
try {
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
if (pkg.name === "@ff-labs/fff-node") {
return dir;
}
} catch {
// Not our package.json, keep going up
}
}
dir = dirname(dir);
}
// Fallback: assume we're one level deep in src/
return dirname(currentDir);
}
/**
* Check if the binary exists in any known location
*/
export function binaryExists(): boolean {
return findBinary() !== null;
}
/**
* Try to resolve the binary from the platform-specific npm package.
*
* When users install @ff-labs/fff-node, npm automatically installs the matching
* optionalDependency (e.g. @ff-labs/fff-bin-darwin-arm64). We resolve the binary
* path by requiring that package's package.json and looking for the binary
* in the same directory.
*/
function resolveFromNpmPackage(): string | null {
const packageName = getNpmPackageName();
try {
// Use createRequire to resolve the platform package's location
const require = createRequire(join(getPackageDir(), "package.json"));
const packageJsonPath = require.resolve(`${packageName}/package.json`);
const packageDir = dirname(packageJsonPath);
const binaryPath = join(packageDir, getLibFilename());
if (existsSync(binaryPath)) {
return binaryPath;
}
} catch {
// Package not installed - this is expected on unsupported platforms
// or when installed without optional dependencies
}
return null;
}
/**
* Get the development binary path (for local development)
*/
function getDevBinaryPath(): string | null {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
const possiblePaths = [
join(workspaceRoot, "target", "release", getLibFilename()),
join(workspaceRoot, "target", "debug", getLibFilename()),
];
for (const path of possiblePaths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
function isDevWorkspace(): boolean {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
return existsSync(join(workspaceRoot, "Cargo.toml"));
}
/**
* Find the native library binary.
*
* Resolution order:
* - Dev workspace: local dev build first, then npm package
* - Production: npm package first, then dev build
*
* @returns Absolute path to the library, or null if not found
*/
export function findBinary(): string | null {
if (isDevWorkspace()) {
// 1. Local bin/ directory (populated by `make prepare-node`)
const binPath = join(getPackageDir(), "bin", getLibFilename());
if (existsSync(binPath)) return binPath;
// 2. Local dev build (target/release or target/debug)
const devPath = getDevBinaryPath();
if (devPath) return devPath;
// 3. Fallback to npm package
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
return null;
}
// Production: npm package first
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
// Fallback: local dev build (e.g. user built from source)
return getDevBinaryPath();
}
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
/**
* FileFinder - High-level API for the fff file finder
*
* This class provides a type-safe, ergonomic API for file finding operations.
* Each instance owns an independent native file picker that can be created
* and destroyed independently. Multiple instances can coexist.
*
* All methods return Result types for explicit error handling.
*/
import {
ensureLoaded,
ffiCreate,
ffiDestroy,
ffiGetHistoricalQuery,
ffiGetScanProgress,
ffiHealthCheck,
ffiIsScanning,
ffiLiveGrep,
ffiMultiGrep,
ffiRefreshGitStatus,
ffiRestartIndex,
ffiScanFiles,
ffiSearch,
ffiTrackQuery,
isAvailable,
type NativeHandle,
} from "./ffi.js";
import type {
GrepOptions,
GrepResult,
HealthCheck,
InitOptions,
MultiGrepOptions,
Result,
ScanProgress,
SearchOptions,
SearchResult,
} from "./types.js";
import { err } from "./types.js";
/**
* FileFinder - Fast file finder with fuzzy search
*
* Each instance is backed by an independent native file picker. Create as many
* as you need and destroy them when done.
*
* @example
*
* ```ts
* import { FileFinder } from "@ff-labs/fff-node";
*
* // Create an instance
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (!finder.ok) {
* console.error(finder.error);
* process.exit(1);
* }
*
* // Wait for initial scan
* finder.value.waitForScan(5000);
*
* // Search for files
* const search = finder.value.search("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup
* finder.value.destroy();
* ```
*/
export class FileFinder {
private handle: NativeHandle | null;
private constructor(handle: NativeHandle) {
this.handle = handle;
}
/**
* Create a new file finder instance.
*
* @param options - Initialization options
* @returns Result containing the new FileFinder instance or an error
*
* @example
* ```typescript
* // Basic initialization
* const finder = FileFinder.create({ basePath: "/path/to/project" });
*
* // With custom database paths
* const finder = FileFinder.create({
* basePath: "/path/to/project",
* frecencyDbPath: "/custom/frecency.mdb",
* historyDbPath: "/custom/history.mdb",
* });
* ```
*/
static create(options: InitOptions): Result<FileFinder> {
const result = ffiCreate(
options.basePath,
options.frecencyDbPath ?? "",
options.historyDbPath ?? "",
options.useUnsafeNoLock ?? false,
options.warmupMmapCache ?? false,
options.aiMode ?? false,
);
if (!result.ok) {
return result;
}
return { ok: true, value: new FileFinder(result.value) };
}
/**
* Destroy and clean up all resources.
*
* Call this when you're done using the file finder to free memory
* and stop background file watching. After calling this, the instance
* must not be used again.
*/
destroy(): void {
if (this.handle !== null) {
ffiDestroy(this.handle);
this.handle = null;
}
}
/**
* Check if this instance has been destroyed.
*/
get isDestroyed(): boolean {
return this.handle === null;
}
/**
* Guard that returns an error if the instance has been destroyed.
*/
private ensureAlive(): Result<NativeHandle> {
if (this.handle === null) {
return err("FileFinder instance has been destroyed.");
}
return { ok: true, value: this.handle };
}
/**
* Search for files matching the query.
*
* The query supports fuzzy matching and special syntax:
* - `foo bar` - Match files containing "foo" and "bar"
* - `src/` - Match files in src directory
* - `file.ts:42` - Match file.ts with line 42
* - `file.ts:42:10` - Match file.ts with line 42, column 10
*
* @param query - Search query string
* @param options - Search options
* @returns Search results with matched files and scores
*
* @example
* ```typescript
* const result = finder.search("main.ts", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} files`);
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
fileSearch(query: string, options?: SearchOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearch(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
* Search file contents (live grep).
*
* Searches through the contents of indexed files using the specified mode:
* - `"plain"` (default): SIMD-accelerated literal text matching
* - `"regex"`: Regular expression matching
* - `"fuzzy"`: Smith-Waterman fuzzy matching per line
*
* Supports pagination for large result sets. The result includes a `nextCursor`
* that can be passed back to fetch the next page.
*
* The query also supports constraint syntax:
* - `*.ts pattern` - Only search in TypeScript files
* - `src/ pattern` - Only search in the src directory
*
* @param query - Search query string
* @param options - Grep options (mode, pagination, limits)
* @returns Grep results with matched lines and file metadata
*
* @example
* ```typescript
* // First page
* const result = finder.grep("TODO", { mode: "plain" });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* // Fetch next page
* if (result.value.nextCursor) {
* const page2 = finder.grep("TODO", {
* cursor: result.value.nextCursor,
* });
* }
* }
* ```
*/
grep(query: string, options?: GrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiLiveGrep(
guard.value,
query,
options?.mode ?? "plain",
options?.maxFileSize ?? 0,
options?.maxMatchesPerFile ?? 0,
options?.smartCase ?? true,
options?.cursor?._offset ?? 0,
0, // page_limit (0 = default 50)
options?.timeBudgetMs ?? 0,
options?.beforeContext ?? 0,
options?.afterContext ?? 0,
false,
);
}
/**
* 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");
}
return ffiMultiGrep(
guard.value,
options.patterns.join("\n"),
options.constraints ?? "",
options.maxFileSize ?? 0,
options.maxMatchesPerFile ?? 0,
options.smartCase ?? true,
options.cursor?._offset ?? 0,
0, // page_limit (0 = default 50)
options.timeBudgetMs ?? 0,
options.beforeContext ?? 0,
options.afterContext ?? 0,
false,
);
}
/**
* Trigger a rescan of the indexed directory.
*
* This is useful after major file system changes that the
* background watcher might have missed.
*/
scanFiles(): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiScanFiles(guard.value);
}
/**
* Check if a scan is currently in progress.
*/
isScanning(): boolean {
if (this.handle === null) return false;
return ffiIsScanning(this.handle);
}
/**
* Get the current scan progress.
*/
getScanProgress(): Result<ScanProgress> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetScanProgress(guard.value) as Result<ScanProgress>;
}
/**
* Wait for the initial file scan to complete.
*
* Non-blocking polls `isScanning` and yields to the event loop between checks.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*
* @example
* ```typescript
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (finder.ok) {
* const completed = await finder.value.waitForScan(10000);
* if (!completed.ok || !completed.value) {
* console.warn("Scan did not complete in time");
* }
* }
* ```
*/
async waitForScan(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (this.isScanning()) {
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return { ok: true, value: true };
}
/**
* Change the indexed directory to a new path.
*
* This stops the current file watcher and starts indexing the new directory.
*
* @param newPath - New directory path to index
*/
reindex(newPath: string): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRestartIndex(guard.value, newPath);
}
/**
* Refresh the git status cache.
*
* @returns Number of files with updated git status
*/
refreshGitStatus(): Result<number> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRefreshGitStatus(guard.value);
}
/**
* Track query completion for smart suggestions.
*
* Call this when a user selects a file from search results.
* This helps improve future search rankings for similar queries.
*
* @param query - The search query that was used
* @param selectedFilePath - The file path that was selected
*/
trackQuery(query: string, selectedFilePath: string): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiTrackQuery(guard.value, query, selectedFilePath);
}
/**
* Get a historical query by offset.
*
* @param offset - Offset from most recent (0 = most recent)
* @returns The historical query string, or null if not found
*/
getHistoricalQuery(offset: number): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetHistoricalQuery(guard.value, offset);
}
/**
* Get health check information.
*
* Useful for debugging and verifying the file finder is working correctly.
*
* @param testPath - Optional path to test git repository detection
*/
healthCheck(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(this.handle, testPath || "") as Result<HealthCheck>;
}
/**
* Check if the native library is available.
*/
static isAvailable(): boolean {
return isAvailable();
}
/**
* Ensure the native library is loaded.
*
* Loads the native library from the platform-specific npm package
* or a local dev build. Throws if the binary is not found.
*/
static ensureLoaded(): void {
ensureLoaded();
}
/**
* Get a health check without requiring an instance.
*
* Returns limited info (version + git only, no picker/frecency/query data).
*
* @param testPath - Optional path to test git repository detection
*/
static healthCheckStatic(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* fff - Fast File Finder
*
* High-performance fuzzy file finder for Node.js, powered by Rust.
* Perfect for LLM agent tools that need to search through codebases.
*
* Each `FileFinder` instance is backed by an independent native file picker.
* Create as many as you need and destroy them when done.
*
* Uses ffi-rs to load the same native libfff_c binary used by @ff-labs/fff-bun.
*
* @example
* ```typescript
* import { FileFinder } from "@ff-labs/fff-node";
*
* // Create a file finder instance
* const result = FileFinder.create({ basePath: "/path/to/project" });
* if (!result.ok) {
* console.error(result.error);
* process.exit(1);
* }
* const finder = result.value;
*
* // Wait for initial scan
* finder.waitForScan(5000);
*
* // Search for files
* const search = finder.fileSearch("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup when done
* finder.destroy();
* ```
*
* @packageDocumentation
*/
export {
binaryExists,
findBinary,
} from "./binary.js";
export { closeLibrary } from "./ffi.js";
export { FileFinder } from "./finder.js";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform.js";
export type {
DbHealth,
FileItem,
GrepCursor,
GrepMatch,
GrepMode,
GrepOptions,
GrepResult,
HealthCheck,
InitOptions,
Location,
MultiGrepOptions,
Result,
ScanProgress,
Score,
SearchOptions,
SearchResult,
} from "./types.js";
// Result helpers
export { err, ok } from "./types.js";

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