Compare commits
16 Commits
feat/something
...
node
| Author | SHA1 | Date | |
|---|---|---|---|
| 9adc2d207b | |||
| a03946740f | |||
| 4b83987ea1 | |||
| c6cb66b597 | |||
| 64861f8142 | |||
| eb5f2b3648 | |||
| fcdf4a9172 | |||
| 1001eb8b5e | |||
| f0ce2dd50d | |||
| 1c2a1c1204 | |||
| 66bdfff454 | |||
| 1e50f8df80 | |||
| 736c41ecd6 | |||
| ac8df4c9e4 | |||
| e3e534f4ad | |||
| 764e3ecf18 |
@@ -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:
|
||||
@@ -25,7 +28,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+188
-115
@@ -2,9 +2,14 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/interchangable-ffi, feat/termux]
|
||||
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
|
||||
|
||||
@@ -70,7 +75,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -201,7 +206,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -265,16 +270,102 @@ jobs:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
path: packages/${{ matrix.npm_package }}/
|
||||
|
||||
build-mcp:
|
||||
name: Build MCP ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
## Linux builds (using cargo-zigbuild)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/fff-mcp
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/fff-mcp
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/fff-mcp
|
||||
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff-mcp.exe
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mcp-${{ matrix.target }}
|
||||
path: fff-mcp-${{ matrix.target }}*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: [build-nvim, build-c]
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
# do not create releases on the forks (no permissions)
|
||||
if: github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dmtrKovalenko'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -311,6 +402,19 @@ jobs:
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Flatten MCP artifacts
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
for dir in mcp-*/; do
|
||||
for file in "$dir"*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
mv "$file" "./$filename"
|
||||
fi
|
||||
done
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Remove npm package artifacts from release binaries
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
@@ -326,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
|
||||
@@ -352,39 +453,60 @@ jobs:
|
||||
## C FFI Library (for Bun/Node/Python)
|
||||
- `c-lib-{target}.so` / `.dylib` / `.dll` - C FFI library
|
||||
|
||||
## MCP Server
|
||||
- `fff-mcp-{target}` - MCP server binary
|
||||
|
||||
Install with:
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
|
||||
```
|
||||
|
||||
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
|
||||
@@ -397,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]
|
||||
runs-on: ubuntu-latest
|
||||
# comments doesn't work on forks
|
||||
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
- name: 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)"
|
||||
|
||||
@@ -20,16 +20,16 @@ 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
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
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,11 +56,11 @@ 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
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,3 +13,12 @@ result
|
||||
big-repo/
|
||||
# all the perf like utility files
|
||||
*.data
|
||||
node_modules/
|
||||
|
||||
dist/
|
||||
scripts/benchmark-results/
|
||||
|
||||
# Native binaries (downloaded at install)
|
||||
*.dylib
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+600
-85
@@ -45,12 +45,56 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -63,12 +107,29 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
@@ -93,7 +154,7 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
@@ -154,6 +215,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -252,6 +319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -260,8 +328,22 @@ version = "4.5.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -270,6 +352,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
@@ -294,7 +382,7 @@ dependencies = [
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
@@ -315,7 +403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -377,6 +465,40 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
@@ -433,12 +555,24 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.13"
|
||||
@@ -457,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",
|
||||
@@ -469,49 +603,34 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-core"
|
||||
version = "0.1.0"
|
||||
name = "fff-grep"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-mcp"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fff-query-parser",
|
||||
"fff-search",
|
||||
"git2",
|
||||
"mimalloc",
|
||||
"rmcp",
|
||||
"schemars",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-nvim"
|
||||
version = "0.1.0"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"blake3",
|
||||
@@ -519,8 +638,8 @@ dependencies = [
|
||||
"criterion",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"fff-core",
|
||||
"fff-query-parser",
|
||||
"fff-search",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"heed",
|
||||
@@ -540,19 +659,61 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -580,6 +741,94 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
@@ -650,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"
|
||||
@@ -670,6 +910,18 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heed"
|
||||
version = "0.22.0"
|
||||
@@ -824,6 +1076,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.0.3"
|
||||
@@ -861,6 +1119,16 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.0"
|
||||
@@ -889,9 +1157,15 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
@@ -901,6 +1175,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -1141,36 +1424,14 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
|
||||
dependencies = [
|
||||
"multiversion-macros",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion-macros"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3f70b45907246d13fbe88cee200ebba81c9b77476028133a527c88bd875bc7"
|
||||
checksum = "8c3404d265de0390885288ecf22fcdc4fb77519fe58168bac3d26ce65033b18b"
|
||||
dependencies = [
|
||||
"multiversion",
|
||||
"rayon",
|
||||
"itertools 0.14.0",
|
||||
"raw-cpuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1276,6 +1537,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
@@ -1327,6 +1594,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -1509,6 +1782,15 @@ dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.10.0"
|
||||
@@ -1549,6 +1831,26 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
@@ -1593,6 +1895,41 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2cb14cb9278a12eae884c9f3c0cfeca2cc28f361211206424a1d7abed95f090"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a02ea81d9482b07e1fe156ac7cf98b6823d51fb84531936a5e1cbb4eec31ad5"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -1639,6 +1976,32 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -1665,6 +2028,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.140"
|
||||
@@ -1677,6 +2051,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1692,12 +2075,28 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -1716,6 +2115,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
@@ -1728,6 +2137,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.104"
|
||||
@@ -1759,12 +2174,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-features"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.20.0"
|
||||
@@ -1878,6 +2287,88 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
@@ -1974,6 +2465,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -2208,6 +2705,15 @@ dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
@@ -2394,6 +2900,15 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.39.0"
|
||||
|
||||
+11
-4
@@ -2,13 +2,17 @@
|
||||
members = [
|
||||
"crates/fff-c",
|
||||
"crates/fff-core",
|
||||
"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"
|
||||
@@ -24,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"
|
||||
@@ -32,7 +35,7 @@ mimalloc = "0.1.47"
|
||||
zlob = "1.3.0"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = "0.8.1"
|
||||
neo_frizbee = "0.8.2"
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.7"
|
||||
once_cell = "1.20.2"
|
||||
@@ -40,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"
|
||||
|
||||
|
||||
@@ -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 \
|
||||
@@ -25,14 +28,65 @@ prepare-bun: build
|
||||
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null; \
|
||||
true
|
||||
|
||||
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
|
||||
format-lua:
|
||||
stylua .
|
||||
format-ts:
|
||||
bun format
|
||||
|
||||
format: format-rust format-lua
|
||||
format: format-rust format-lua format-ts
|
||||
|
||||
lint-rust:
|
||||
cargo clippy --workspace --features zlob -- -D warnings
|
||||
lint-lua:
|
||||
~/.luarocks/bin/luacheck .
|
||||
lint-ts:
|
||||
bun lint
|
||||
|
||||
lint: lint-rust lint-lua lint-ts
|
||||
|
||||
check: format lint
|
||||
|
||||
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,9 +1,13 @@
|
||||
<p align="center">
|
||||
<h2 align="center">FFF.nvim</h2>
|
||||
<h1 align="center">FFF</h1>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Finally a smart fuzzy file picker for neovim.
|
||||
<a href="#mcp"><strong>AI agents (MCP)</strong></a> | <a href="#neovim-guide"><strong>Neovim users</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<i>A fast file search for your AI and neovim, with memory built-in</i>
|
||||
</p>
|
||||
|
||||
<p align="center" style="text-decoration: none; border: none;">
|
||||
@@ -14,35 +18,44 @@
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none"> <img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
|
||||
</p>
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
---
|
||||
|
||||
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
|
||||
**FFF** stands for ~~freakin fast fuzzy file finder~~ (pick 3) and it is an opinionated fuzzy file picker for your AI agent and Neovim. Just for file search, but we do the file search really fff well.
|
||||
|
||||
## Features
|
||||
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping with a strong focus on performance and useful search results. For humans - provides an unbelievable typo-resistant experience, for AI agents - implements the fastest file search with additional free memory suggesting the best search results based on various factors like frecency, git status, file size, definition matches, and more.
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- [Typo resistant fuzzy search](https://github.com/saghen/frizbee)
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
## MCP
|
||||
|
||||
## Installation
|
||||
FFF is an amazing way to reduce the time and tokens by giving your AI agent a bit of memory built-in to their file search tools. It makes your AI harness to find the code faster and spend less tokens by doing less roundtrips and reading less useless files.
|
||||
|
||||
> [!NOTE]
|
||||
> Although we'll try to make sure to keep 100% backward compatibility, by using you should understand that silly bugs and breaking changes may happen.
|
||||
> And also we hope for your contributions and feedback to make this plugin ideal for everyone.
|
||||

|
||||
|
||||
### Prerequisites
|
||||
You can install FFF as a dependency for your AI agent using a simple bash script:
|
||||
|
||||
FFF.nvim requires:
|
||||
```bash
|
||||
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
|
||||
```
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
> The installation script is here [./install-mcp.sh](./install-mcp.sh) if you want to review it before running.
|
||||
|
||||
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
|
||||
Here is an example addition to `CLAUDE.md` that works perfectly:
|
||||
|
||||
```sh
|
||||
# CLAUDE.md
|
||||
For any file search or grep in the current git indexed directory use fff tools
|
||||
```
|
||||
|
||||
## Neovim guide
|
||||
|
||||
Here is some demo on the linux repository (100k files, 8GB) but you better fill it yourself and see the magic
|
||||
|
||||
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
|
||||
### Installation
|
||||
|
||||
FFF.nvim requires neovim 0.10.0 or higher
|
||||
|
||||
#### lazy.nvim
|
||||
|
||||
```lua
|
||||
@@ -253,9 +266,8 @@ require('fff').setup({
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
enabled = false, -- Show file info panel in preview
|
||||
show_scores = false, -- Show scores inline in the UI
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -282,19 +294,19 @@ require('fff').setup({
|
||||
#### Available Methods
|
||||
|
||||
```lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repository
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file list
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
```
|
||||
|
||||
just jump to the definition and see what other APIs are exposed we have a plenty
|
||||
|
||||
#### Commands
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
@@ -302,10 +314,6 @@ FFF.nvim provides several commands for interacting with the file picker:
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
#### Multiline Paste Support
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Toggle scoring information display:
|
||||
@@ -366,6 +374,32 @@ require('fff').live_grep({ query = 'search term' })
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
|
||||
|
||||
#### Constraints
|
||||
|
||||
There are a number of constraints you can use to refine your search in both grep and file search mode:
|
||||
|
||||
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
|
||||
- `test/` - any deeply nested children of any test/ dir
|
||||
- `!something` - exclude results matching something
|
||||
- `!test/`, `!git:modified` - combining with any other constraint works as negation
|
||||
- `./**/*.{rs,lua}` - any valid glob expression via [the fastest globbing library](https://github.com/dmtrKovalenko/zlob)
|
||||
|
||||
For grep only:
|
||||
|
||||
- `*.md`, `*.{c,h}` - extension filtering
|
||||
- `src/main.rs` - grep in a single file
|
||||
|
||||
In addition to that, all constraints can be combined together like:
|
||||
|
||||
```
|
||||
git:modified src/**/*.rs !src/**/mod.rs user controller
|
||||
```
|
||||
|
||||
This will find all the files that qualify the constraints and:
|
||||
|
||||
- match **both** user and controller (for file mode)
|
||||
- match "user controller" (for grep mode)
|
||||
|
||||
#### Cross-Mode Suggestions
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search mode and displays the results as suggestions:
|
||||
@@ -459,41 +493,8 @@ Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
```vim
|
||||
```
|
||||
:FFFOpenLog
|
||||
```
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
|
||||
|
||||
#### Common Issues
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it's properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"files": {
|
||||
"includes": ["packages/**/*.ts", "!packages/*/dist"],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 90
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
},
|
||||
},
|
||||
"packages/fff-bun": {
|
||||
"name": "@ff-labs/fff-bun",
|
||||
"version": "0.1.37",
|
||||
"bin": {
|
||||
"fff": "./scripts/cli.ts",
|
||||
"fff-demo": "./examples/search.ts",
|
||||
"fff-grep": "./examples/grep.ts",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-x64": "0.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0",
|
||||
},
|
||||
},
|
||||
"packages/fff-node": {
|
||||
"name": "@ff-labs/fff-node",
|
||||
"version": "0.1.37",
|
||||
"bin": {
|
||||
"fff-node": "./dist/scripts/cli.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"ffi-rs": "^1.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@workspace:packages/fff-bun"],
|
||||
|
||||
"@ff-labs/fff-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=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA=="],
|
||||
|
||||
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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 */
|
||||
+432
-255
@@ -1,148 +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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -152,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,
|
||||
@@ -167,186 +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()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grep (live search) types
|
||||
// ============================================================================
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grep result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Grep search options (JSON-deserializable)
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct GrepSearchOptionsJson {
|
||||
/// Maximum file size to search (bytes, default: 10MB)
|
||||
pub max_file_size: Option<u64>,
|
||||
/// Maximum matches per file (default: 200)
|
||||
pub max_matches_per_file: Option<usize>,
|
||||
/// Smart case: case-insensitive if query is lowercase (default: true)
|
||||
pub smart_case: Option<bool>,
|
||||
/// File-based pagination offset (default: 0)
|
||||
pub file_offset: Option<usize>,
|
||||
/// Maximum matches to return (default: 50)
|
||||
pub page_limit: Option<usize>,
|
||||
/// Search mode: "plain", "regex", or "fuzzy" (default: "plain")
|
||||
pub mode: Option<String>,
|
||||
/// Time budget in milliseconds, 0 = unlimited (default: 0)
|
||||
pub time_budget_ms: Option<u64>,
|
||||
/// A byte range within a matched line, used for highlighting.
|
||||
#[repr(C)]
|
||||
pub struct FffMatchRange {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
// -- 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// ## 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,
|
||||
}
|
||||
|
||||
+488
-238
File diff suppressed because it is too large
Load Diff
@@ -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,8 @@ 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 }
|
||||
ignore = { workspace = true }
|
||||
@@ -50,7 +49,9 @@ parking_lot = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
toml = "0.8"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { workspace = true, optional = true }
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::file_picker::{FFFMode, FilePicker};
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
@@ -8,19 +8,38 @@ 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);
|
||||
const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
|
||||
/// Minimum seconds between frecency tracks of the same file in AI mode.
|
||||
/// Prevents score inflation from rapid burst edits by AI agents.
|
||||
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
|
||||
|
||||
impl BackgroundWatcher {
|
||||
pub fn new(
|
||||
@@ -28,18 +47,45 @@ impl BackgroundWatcher {
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing background watcher for path: {}",
|
||||
base_path.display()
|
||||
"Initializing background watcher for path: {}, mode: {:?}",
|
||||
base_path.display(),
|
||||
mode,
|
||||
);
|
||||
|
||||
let debouncer =
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency)?;
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,6 +94,7 @@ impl BackgroundWatcher {
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) -> Result<Debouncer, Error> {
|
||||
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
|
||||
// files that could be git ignored, we have to property differentiate those and if
|
||||
@@ -66,6 +113,7 @@ impl BackgroundWatcher {
|
||||
&git_workdir_for_handler,
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
Err(errors) => {
|
||||
@@ -123,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +197,7 @@ fn handle_debounced_events(
|
||||
git_workdir: &Option<PathBuf>,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) {
|
||||
// this will be called very often, we have to minimiy the lock time for file picker
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
@@ -316,6 +363,50 @@ fn handle_debounced_events(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// AI mode: auto-track frecency for all modified/created files.
|
||||
// Uses a 5-minute cooldown per file to prevent score inflation from rapid
|
||||
// burst edits (AI agents often edit the same file many times in minutes).
|
||||
// This runs after apply_changes so the picker write lock is released.
|
||||
if mode.is_ai() && !paths_to_add_or_modify.is_empty() {
|
||||
let mut tracked_count = 0usize;
|
||||
if let Ok(frecency_guard) = shared_frecency.read()
|
||||
&& let Some(ref frecency) = *frecency_guard
|
||||
{
|
||||
for path in &paths_to_add_or_modify {
|
||||
// Skip if this file was tracked less than 5 minutes ago
|
||||
let should_track = match frecency.seconds_since_last_access(path) {
|
||||
Ok(Some(secs)) => secs >= AI_MODE_COOLDOWN_SECS,
|
||||
Ok(None) => true, // Never tracked before
|
||||
Err(_) => true, // DB error, track anyway
|
||||
};
|
||||
if !should_track {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = frecency.track_access(path) {
|
||||
error!("Failed to track frecency for {:?}: {:?}", path, e);
|
||||
} else {
|
||||
tracked_count += 1;
|
||||
}
|
||||
}
|
||||
if tracked_count > 0 {
|
||||
info!("AI mode: tracked frecency for {} files", tracked_count);
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-memory frecency scores for tracked files
|
||||
if tracked_count > 0
|
||||
&& let Ok(mut picker_guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *picker_guard
|
||||
&& let Ok(frecency_guard) = shared_frecency.read()
|
||||
&& let Some(ref frecency) = *frecency_guard
|
||||
{
|
||||
for path in &paths_to_add_or_modify {
|
||||
let _ = picker.update_single_file_frecency(path, frecency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Git status updates require a repository.
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
debug!("No git repo available, skipping git status updates");
|
||||
|
||||
@@ -32,6 +32,28 @@ pub trait Constrainable {
|
||||
fn git_status(&self) -> Option<git2::Status>;
|
||||
}
|
||||
|
||||
/// Check if a relative path ends with the given suffix at a `/` boundary (case-insensitive).
|
||||
///
|
||||
/// Returns `true` when the path equals the suffix or the character before the suffix
|
||||
/// in the path is `/`. This ensures partial directory-name matches are rejected.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `path_ends_with_suffix("libswscale/input.c", "libswscale/input.c")` → true (exact)
|
||||
/// - `path_ends_with_suffix("foo/libswscale/input.c", "libswscale/input.c")` → true (suffix)
|
||||
/// - `path_ends_with_suffix("xlibswscale/input.c", "libswscale/input.c")` → false (no boundary)
|
||||
#[inline]
|
||||
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
|
||||
if path.len() < suffix.len() {
|
||||
return false;
|
||||
}
|
||||
let start = path.len() - suffix.len();
|
||||
if !path[start..].eq_ignore_ascii_case(suffix) {
|
||||
return false;
|
||||
}
|
||||
// Exact match, or the character before is /
|
||||
start == 0 || path.as_bytes()[start - 1] == b'/'
|
||||
}
|
||||
|
||||
/// Check if file extension matches (without allocation)
|
||||
#[inline]
|
||||
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
|
||||
@@ -44,12 +66,14 @@ pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Check if path contains segment (without allocation)
|
||||
/// Supports both single segments ("src") and multi-segment paths ("libswscale/aarch64").
|
||||
/// For "libswscale/aarch64", checks that these appear as consecutive path components.
|
||||
#[inline]
|
||||
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
|
||||
let path_bytes = path.as_bytes();
|
||||
let segment_len = segment.len();
|
||||
|
||||
// Check segment/ at start
|
||||
// Check segment/ at start of path
|
||||
if path.len() > segment_len
|
||||
&& path_bytes.get(segment_len) == Some(&b'/')
|
||||
&& path[..segment_len].eq_ignore_ascii_case(segment)
|
||||
@@ -101,6 +125,7 @@ fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
return if negate { !result } else { result };
|
||||
}
|
||||
Constraint::PathSegment(segment) => path_contains_segment(item.relative_path(), segment),
|
||||
Constraint::FilePath(suffix) => path_ends_with_suffix(item.relative_path(), suffix),
|
||||
Constraint::GitStatus(status_filter) => match (item.git_status(), status_filter) {
|
||||
(Some(status), GitStatusFilter::Modified) => is_modified_status(status),
|
||||
(Some(status), GitStatusFilter::Untracked) => status.contains(git2::Status::WT_NEW),
|
||||
@@ -347,8 +372,78 @@ mod tests {
|
||||
// Should not match filename
|
||||
assert!(!path_contains_segment("lib/src", "src"));
|
||||
|
||||
// Multi-segment constraints
|
||||
assert!(path_contains_segment(
|
||||
"libswscale/aarch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
));
|
||||
assert!(path_contains_segment(
|
||||
"foo/libswscale/aarch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
));
|
||||
assert!(path_contains_segment(
|
||||
"foo/LibSwscale/AArch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
)); // case-insensitive
|
||||
assert!(!path_contains_segment(
|
||||
"xlibswscale/aarch64/input.S",
|
||||
"libswscale/aarch64"
|
||||
)); // partial match at start
|
||||
assert!(!path_contains_segment(
|
||||
"foo/libswscale/aarch64x/input.S",
|
||||
"libswscale/aarch64"
|
||||
)); // partial match at end
|
||||
assert!(path_contains_segment(
|
||||
"crates/fff-core/src/grep.rs",
|
||||
"fff-core/src"
|
||||
));
|
||||
|
||||
// Edge cases
|
||||
assert!(!path_contains_segment("", "src"));
|
||||
assert!(!path_contains_segment("src", "src")); // no trailing slash
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_ends_with_suffix() {
|
||||
// Exact match
|
||||
assert!(path_ends_with_suffix(
|
||||
"libswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Suffix match at / boundary
|
||||
assert!(path_ends_with_suffix(
|
||||
"foo/libswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Deep nesting
|
||||
assert!(path_ends_with_suffix(
|
||||
"a/b/c/libswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// No boundary — partial directory name
|
||||
assert!(!path_ends_with_suffix(
|
||||
"xlibswscale/input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Case insensitive
|
||||
assert!(path_ends_with_suffix(
|
||||
"foo/LibSwscale/Input.C",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
|
||||
// Single file name
|
||||
assert!(path_ends_with_suffix("input.c", "input.c"));
|
||||
assert!(!path_ends_with_suffix("xinput.c", "input.c"));
|
||||
|
||||
// Suffix longer than path
|
||||
assert!(!path_ends_with_suffix("input.c", "foo/input.c"));
|
||||
|
||||
// Simple path
|
||||
assert!(path_ends_with_suffix("src/main.rs", "src/main.rs"));
|
||||
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -19,31 +52,28 @@ use std::sync::{
|
||||
use std::time::SystemTime;
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
|
||||
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
|
||||
#[inline]
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
// Empty files are not binary
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum FFFMode {
|
||||
#[default]
|
||||
Neovim,
|
||||
Ai,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
impl FFFMode {
|
||||
pub fn is_ai(self) -> bool {
|
||||
self == FFFMode::Ai
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -169,16 +199,24 @@ impl FileItem {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> {
|
||||
self.access_frecency_score = tracker.get_access_score(&self.path);
|
||||
pub fn update_frecency_scores(
|
||||
&mut self,
|
||||
tracker: &FrecencyTracker,
|
||||
mode: FFFMode,
|
||||
) -> Result<(), Error> {
|
||||
self.access_frecency_score = tracker.get_access_score(&self.path, mode);
|
||||
self.modification_frecency_score =
|
||||
tracker.get_modification_score(self.modified, self.git_status);
|
||||
tracker.get_modification_score(self.modified, self.git_status, mode);
|
||||
self.total_frecency_score = self.access_frecency_score + self.modification_frecency_score;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -186,6 +224,8 @@ pub struct FilePicker {
|
||||
scanned_files_count: Arc<AtomicUsize>,
|
||||
background_watcher: Option<BackgroundWatcher>,
|
||||
warmup_mmap_cache: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
mode: FFFMode,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
@@ -211,6 +251,10 @@ impl FilePicker {
|
||||
self.warmup_mmap_cache
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> FFFMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn git_root(&self) -> Option<&Path> {
|
||||
self.sync_data.git_workdir.as_deref()
|
||||
}
|
||||
@@ -232,12 +276,13 @@ impl FilePicker {
|
||||
pub fn new_with_shared_state(
|
||||
base_path: String,
|
||||
warmup_mmap_cache: bool,
|
||||
mode: FFFMode,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
info!(
|
||||
"Initializing FilePicker with base_path: {}, warmup: {}",
|
||||
base_path, warmup_mmap_cache
|
||||
"Initializing FilePicker with base_path: {}, warmup: {}, mode: {:?}",
|
||||
base_path, warmup_mmap_cache, mode
|
||||
);
|
||||
let path = PathBuf::from(&base_path);
|
||||
if !path.exists() {
|
||||
@@ -250,6 +295,7 @@ impl FilePicker {
|
||||
// rather than a stale `false` (the thread hasn't started yet).
|
||||
let scan_signal = Arc::new(AtomicBool::new(true));
|
||||
let synced_files_count = Arc::new(AtomicUsize::new(0));
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let picker = FilePicker {
|
||||
base_path: path.clone(),
|
||||
@@ -258,6 +304,8 @@ impl FilePicker {
|
||||
scanned_files_count: Arc::clone(&synced_files_count),
|
||||
background_watcher: None,
|
||||
warmup_mmap_cache,
|
||||
cancelled: Arc::clone(&cancelled),
|
||||
mode,
|
||||
};
|
||||
|
||||
// Place the picker into the shared handle before spawning the
|
||||
@@ -272,8 +320,10 @@ impl FilePicker {
|
||||
Arc::clone(&scan_signal),
|
||||
Arc::clone(&synced_files_count),
|
||||
warmup_mmap_cache,
|
||||
mode,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
cancelled,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -281,27 +331,27 @@ 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 = options.max_threads.max(1);
|
||||
let max_threads = if options.max_threads == 0 {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
} else {
|
||||
options.max_threads
|
||||
};
|
||||
|
||||
debug!(
|
||||
?query,
|
||||
parsed_is_some = parsed.is_some(),
|
||||
raw_query = ?query.raw_query,
|
||||
pagination = ?options.pagination,
|
||||
?max_threads,
|
||||
current_file = ?options.current_file,
|
||||
@@ -309,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,
|
||||
@@ -361,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);
|
||||
@@ -381,6 +448,7 @@ impl FilePicker {
|
||||
"Updating git status",
|
||||
);
|
||||
|
||||
let mode = self.mode;
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
@@ -390,7 +458,7 @@ impl FilePicker {
|
||||
if let Some(file) = self.get_mut_file_by_path(&path) {
|
||||
file.git_status = Some(status);
|
||||
if let Some(ref f) = *frecency {
|
||||
file.update_frecency_scores(f)?;
|
||||
file.update_frecency_scores(f, mode)?;
|
||||
}
|
||||
} else {
|
||||
error!(?path, "Couldn't update the git status for path");
|
||||
@@ -449,7 +517,7 @@ impl FilePicker {
|
||||
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
|
||||
&& let Some(file) = self.sync_data.get_file_mut(index)
|
||||
{
|
||||
file.update_frecency_scores(frecency_tracker)?;
|
||||
file.update_frecency_scores(frecency_tracker, self.mode)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -578,8 +646,13 @@ impl FilePicker {
|
||||
.retain_files(|file| !file.path.starts_with(dir_path))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -593,8 +666,12 @@ impl FilePicker {
|
||||
self.is_scanning.store(true, Ordering::Relaxed);
|
||||
self.scanned_files_count.store(0, Ordering::Relaxed);
|
||||
|
||||
let scan_result =
|
||||
scan_filesystem(&self.base_path, &self.scanned_files_count, shared_frecency);
|
||||
let scan_result = scan_filesystem(
|
||||
&self.base_path,
|
||||
&self.scanned_files_count,
|
||||
shared_frecency,
|
||||
self.mode,
|
||||
);
|
||||
match scan_result {
|
||||
Ok(sync) => {
|
||||
info!(
|
||||
@@ -619,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)
|
||||
}
|
||||
@@ -628,22 +706,52 @@ 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,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_scan_and_watcher(
|
||||
base_path: PathBuf,
|
||||
scan_signal: Arc<AtomicBool>,
|
||||
synced_files_count: Arc<AtomicUsize>,
|
||||
warmup_mmap_cache: bool,
|
||||
mode: FFFMode,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
// scan_signal is already `true` (set by the caller before spawning)
|
||||
@@ -651,8 +759,14 @@ fn spawn_scan_and_watcher(
|
||||
info!("Starting initial file scan");
|
||||
|
||||
let mut git_workdir = None;
|
||||
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency) {
|
||||
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency, mode) {
|
||||
Ok(sync) => {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Scan completed but picker was replaced, discarding results");
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Initial filesystem scan completed: found {} files",
|
||||
sync.files.len()
|
||||
@@ -672,13 +786,8 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
|
||||
// The aggressive parallel warmup was causing cache thrashing and delaying
|
||||
// initial searches. Now it runs async and doesn't block.
|
||||
//
|
||||
// We warmup under a read lock on the picker's actual files so that
|
||||
// the OnceLock<Mmap> instances are populated in-place — no clone needed.
|
||||
// Read locks allow concurrent readers so this doesn't block searches.
|
||||
if warmup_mmap_cache
|
||||
&& !cancelled.load(Ordering::Acquire)
|
||||
&& let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
@@ -691,15 +800,31 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
|
||||
// Don't create a watcher if this picker instance was already replaced
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, skipping background watcher creation");
|
||||
return;
|
||||
}
|
||||
|
||||
match BackgroundWatcher::new(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
mode,
|
||||
) {
|
||||
Ok(watcher) => {
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
// Final cancellation check: if the picker was replaced between
|
||||
// watcher creation and this write, drop the watcher instead of
|
||||
// storing it in the wrong picker.
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, dropping orphaned watcher");
|
||||
drop(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.background_watcher = Some(watcher);
|
||||
@@ -733,11 +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);
|
||||
}
|
||||
@@ -748,6 +872,7 @@ fn scan_filesystem(
|
||||
base_path: &Path,
|
||||
synced_files_count: &Arc<AtomicUsize>,
|
||||
shared_frecency: &SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) -> Result<FileSync, Error> {
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
use std::thread;
|
||||
@@ -792,7 +917,7 @@ fn scan_filesystem(
|
||||
.build_parallel();
|
||||
|
||||
let walker_start = std::time::Instant::now();
|
||||
info!("SCAN: Starting file walker");
|
||||
debug!("SCAN: Starting file walker");
|
||||
|
||||
let files = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
walker.run(|| {
|
||||
@@ -837,6 +962,7 @@ fn scan_filesystem(
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
|
||||
files
|
||||
.par_iter_mut()
|
||||
.try_for_each(|file| -> Result<(), Error> {
|
||||
@@ -845,7 +971,7 @@ fn scan_filesystem(
|
||||
}
|
||||
|
||||
if let Some(frecency) = frecency.as_ref() {
|
||||
file.update_frecency_scores(frecency)?;
|
||||
file.update_frecency_scores(frecency, mode)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -873,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)
|
||||
}
|
||||
|
||||
+267
-18
@@ -1,11 +1,14 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::{error::Error, git::is_modified_status};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::{SharedFrecency, git::is_modified_status};
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{
|
||||
EnvFlags,
|
||||
types::{Bytes, SerdeBincode},
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{collections::VecDeque, path::Path};
|
||||
|
||||
@@ -13,6 +16,10 @@ const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
|
||||
const SECONDS_PER_DAY: f64 = 86400.0;
|
||||
const MAX_HISTORY_DAYS: f64 = 30.0; // Only consider accesses within 30 days
|
||||
|
||||
// AI mode: faster decay since AI sessions are shorter and more intense
|
||||
const AI_DECAY_CONSTANT: f64 = 0.231; // ln(2)/3 for 3-day half-life
|
||||
const AI_MAX_HISTORY_DAYS: f64 = 7.0; // Only consider accesses within 7 days
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FrecencyTracker {
|
||||
env: Env,
|
||||
@@ -27,12 +34,21 @@ const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(1, 60 * 60 * 24 * 7), // 1 week
|
||||
];
|
||||
|
||||
// AI mode: compressed thresholds since AI edits happen in rapid bursts
|
||||
const AI_MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(16, 30), // 30 seconds
|
||||
(8, 60 * 5), // 5 minutes
|
||||
(4, 60 * 15), // 15 minutes
|
||||
(2, 60 * 60), // 1 hour
|
||||
(1, 60 * 60 * 4), // 4 hours
|
||||
];
|
||||
|
||||
impl DbHealthChecker for FrecencyTracker {
|
||||
fn get_env(&self) -> &heed::Env {
|
||||
&self.env
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -41,10 +57,13 @@ 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
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
}
|
||||
@@ -65,7 +84,209 @@ impl FrecencyTracker {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
|
||||
/// 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.
|
||||
///
|
||||
/// 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))?)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
|
||||
fn run_frecency_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
|
||||
let start = std::time::Instant::now();
|
||||
let data_path = PathBuf::from(&db_path).join("data.mdb");
|
||||
|
||||
// Phase 1: Purge stale entries.
|
||||
// The RwLock protects the Option<FrecencyTracker> (not the DB itself),
|
||||
// so a read lock is sufficient — LMDB handles its own write serialization.
|
||||
let (deleted, pruned) = {
|
||||
let guard = match shared.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to acquire read lock: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(ref tracker) = *guard else {
|
||||
return;
|
||||
};
|
||||
match tracker.purge_stale_entries() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
tracing::debug!("Purge failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if deleted > 0 || pruned > 0 {
|
||||
tracing::info!(deleted, pruned, elapsed = ?start.elapsed(), "Frecency GC purged entries");
|
||||
}
|
||||
|
||||
// Compact if we purged entries OR the file has significant freelist bloat
|
||||
let file_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
|
||||
if deleted == 0 && pruned == 0 && file_size <= 512 * 1024 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Manual compaction under a single write lock
|
||||
let mut guard = match shared.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to acquire write lock: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Read all entries from current env
|
||||
let entries: Vec<(Vec<u8>, VecDeque<u64>)> = match guard.as_ref() {
|
||||
Some(tracker) => {
|
||||
let rtxn = match tracker.env.read_txn() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::debug!("Compaction read_txn failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let iter = match tracker.db.iter(&rtxn) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::debug!("Compaction iter failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut read_errors = 0u32;
|
||||
for result in iter {
|
||||
match result {
|
||||
Ok((key, value)) => entries.push((key.to_vec(), value)),
|
||||
Err(_) => read_errors += 1,
|
||||
}
|
||||
}
|
||||
if read_errors > 0 {
|
||||
tracing::warn!(
|
||||
read_errors,
|
||||
"Skipped corrupted entries during compaction read"
|
||||
);
|
||||
}
|
||||
entries
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Drop old tracker, delete files, create fresh env, write back
|
||||
*guard = None;
|
||||
|
||||
let lock_path = PathBuf::from(&db_path).join("lock.mdb");
|
||||
let _ = fs::remove_file(&data_path);
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
|
||||
let tracker = match FrecencyTracker::new(&db_path, use_unsafe_no_lock) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("Compaction reopen failed, frecency disabled: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let write_result = (|| -> std::result::Result<(), heed::Error> {
|
||||
let mut wtxn = tracker.env.write_txn()?;
|
||||
for (key, value) in &entries {
|
||||
tracker.db.put(&mut wtxn, key.as_slice(), value)?;
|
||||
}
|
||||
wtxn.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match write_result {
|
||||
Ok(()) => {
|
||||
let new_size = fs::metadata(&data_path).map(|m| m.len()).unwrap_or(0);
|
||||
*guard = Some(tracker);
|
||||
tracing::debug!(
|
||||
entries = entries.len(),
|
||||
old_size = file_size,
|
||||
new_size,
|
||||
elapsed = ?start.elapsed(),
|
||||
"Frecency DB compacted"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Compaction write failed, frecency data may be incomplete: {e}");
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes entries where all timestamps are older than MAX_HISTORY_DAYS,
|
||||
/// and prunes stale timestamps from entries that still have recent ones.
|
||||
/// Returns (deleted_count, pruned_count).
|
||||
fn purge_stale_entries(&self) -> Result<(usize, usize)> {
|
||||
let now = self.get_now();
|
||||
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
|
||||
|
||||
// Collect entries to delete or update
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let mut to_delete: Vec<Vec<u8>> = Vec::new();
|
||||
let mut to_update: Vec<(Vec<u8>, VecDeque<u64>)> = Vec::new();
|
||||
|
||||
let iter = self.db.iter(&rtxn).map_err(Error::DbRead)?;
|
||||
for result in iter {
|
||||
let (key, accesses) = result.map_err(Error::DbRead)?;
|
||||
|
||||
// Timestamps are chronologically ordered (oldest at front).
|
||||
// Find the first timestamp that is still within the retention window.
|
||||
let fresh_start = accesses.iter().position(|&ts| ts >= cutoff_time);
|
||||
match fresh_start {
|
||||
None => {
|
||||
// All timestamps are stale — delete the entire entry
|
||||
to_delete.push(key.to_vec());
|
||||
}
|
||||
Some(0) => {
|
||||
// All timestamps are fresh — nothing to do
|
||||
}
|
||||
Some(start) => {
|
||||
// Some timestamps are stale — keep only the fresh ones
|
||||
let pruned: VecDeque<u64> = accesses.iter().skip(start).copied().collect();
|
||||
to_update.push((key.to_vec(), pruned));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(rtxn);
|
||||
|
||||
if to_delete.is_empty() && to_update.is_empty() {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
|
||||
// Apply all changes in a single write transaction
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
for key in &to_delete {
|
||||
self.db.delete(&mut wtxn, key).map_err(Error::DbWrite)?;
|
||||
}
|
||||
for (key, accesses) in &to_update {
|
||||
self.db
|
||||
.put(&mut wtxn, key, accesses)
|
||||
.map_err(Error::DbWrite)?;
|
||||
}
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
Ok((to_delete.len(), to_update.len()))
|
||||
}
|
||||
|
||||
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let key_hash = Self::path_to_hash_bytes(path)?;
|
||||
@@ -79,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()));
|
||||
};
|
||||
@@ -87,7 +308,15 @@ impl FrecencyTracker {
|
||||
Ok(*blake3::hash(key.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
|
||||
/// 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>> {
|
||||
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<()> {
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
let key_hash = Self::path_to_hash_bytes(path)?;
|
||||
@@ -115,7 +344,7 @@ impl FrecencyTracker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_access_score(&self, file_path: &Path) -> i64 {
|
||||
pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
|
||||
let accesses = self
|
||||
.get_accesses(file_path)
|
||||
.ok()
|
||||
@@ -126,10 +355,21 @@ impl FrecencyTracker {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let decay_constant = if mode.is_ai() {
|
||||
AI_DECAY_CONSTANT
|
||||
} else {
|
||||
DECAY_CONSTANT
|
||||
};
|
||||
let max_history_days = if mode.is_ai() {
|
||||
AI_MAX_HISTORY_DAYS
|
||||
} else {
|
||||
MAX_HISTORY_DAYS
|
||||
};
|
||||
|
||||
let now = self.get_now();
|
||||
let mut total_frecency = 0.0;
|
||||
|
||||
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
|
||||
let cutoff_time = now.saturating_sub((max_history_days * SECONDS_PER_DAY) as u64);
|
||||
|
||||
for &access_time in accesses.iter().rev() {
|
||||
if access_time < cutoff_time {
|
||||
@@ -137,7 +377,7 @@ impl FrecencyTracker {
|
||||
}
|
||||
|
||||
let days_ago = (now.saturating_sub(access_time) as f64) / SECONDS_PER_DAY;
|
||||
let decay_factor = (-DECAY_CONSTANT * days_ago).exp();
|
||||
let decay_factor = (-decay_constant * days_ago).exp();
|
||||
total_frecency += decay_factor;
|
||||
}
|
||||
|
||||
@@ -155,24 +395,31 @@ impl FrecencyTracker {
|
||||
&self,
|
||||
modified_time: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
mode: FFFMode,
|
||||
) -> i64 {
|
||||
let is_modified_git_status = git_status.is_some_and(is_modified_status);
|
||||
if !is_modified_git_status {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let thresholds = if mode.is_ai() {
|
||||
&AI_MODIFICATION_THRESHOLDS
|
||||
} else {
|
||||
&MODIFICATION_THRESHOLDS
|
||||
};
|
||||
|
||||
let now = self.get_now();
|
||||
let duration_since = now.saturating_sub(modified_time);
|
||||
|
||||
for i in 0..MODIFICATION_THRESHOLDS.len() {
|
||||
let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i];
|
||||
for i in 0..thresholds.len() {
|
||||
let (current_points, current_threshold) = thresholds[i];
|
||||
|
||||
if duration_since <= current_threshold {
|
||||
if i == 0 || duration_since == current_threshold {
|
||||
return current_points;
|
||||
}
|
||||
|
||||
let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1];
|
||||
let (prev_points, prev_threshold) = thresholds[i - 1];
|
||||
|
||||
let time_range = current_threshold - prev_threshold;
|
||||
let time_offset = duration_since - prev_threshold;
|
||||
@@ -192,6 +439,7 @@ impl FrecencyTracker {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::file_picker::FFFMode;
|
||||
|
||||
fn calculate_test_frecency_score(access_timestamps: &[u64], current_time: u64) -> i64 {
|
||||
let mut total_frecency = 0.0;
|
||||
@@ -269,34 +517,35 @@ mod tests {
|
||||
|
||||
// At 5 minutes: should interpolate between 16 and 8 points
|
||||
let five_minutes_ago = current_time - (5 * 60);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, git_status);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, git_status, FFFMode::Neovim);
|
||||
|
||||
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
|
||||
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
|
||||
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
|
||||
|
||||
let two_minutes_ago = current_time - (2 * 60);
|
||||
let score = tracker.get_modification_score(two_minutes_ago, git_status);
|
||||
let score = tracker.get_modification_score(two_minutes_ago, git_status, FFFMode::Neovim);
|
||||
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
|
||||
|
||||
let fifteen_minutes_ago = current_time - (15 * 60);
|
||||
let score = tracker.get_modification_score(fifteen_minutes_ago, git_status);
|
||||
let score =
|
||||
tracker.get_modification_score(fifteen_minutes_ago, git_status, FFFMode::Neovim);
|
||||
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
|
||||
|
||||
// At 12 hours: should interpolate between 4 and 2 points
|
||||
let twelve_hours_ago = current_time - (12 * 60 * 60);
|
||||
let score = tracker.get_modification_score(twelve_hours_ago, git_status);
|
||||
let score = tracker.get_modification_score(twelve_hours_ago, git_status, FFFMode::Neovim);
|
||||
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
|
||||
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
|
||||
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
|
||||
|
||||
// at 18 hours for more significant interpolation
|
||||
let eighteen_hours_ago = current_time - (18 * 60 * 60);
|
||||
let score = tracker.get_modification_score(eighteen_hours_ago, git_status);
|
||||
let score = tracker.get_modification_score(eighteen_hours_ago, git_status, FFFMode::Neovim);
|
||||
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
|
||||
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
|
||||
|
||||
let score = tracker.get_modification_score(five_minutes_ago, None);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, None, FFFMode::Neovim);
|
||||
assert_eq!(score, 0, "No git status should return 0");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
+16
-12
@@ -125,31 +125,35 @@ pub fn is_modified_status(status: Status) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
|
||||
match status {
|
||||
None => "clean",
|
||||
None => Some("clean"),
|
||||
Some(status) => {
|
||||
if status.contains(Status::WT_NEW) {
|
||||
"untracked"
|
||||
Some("untracked")
|
||||
} else if status.contains(Status::WT_MODIFIED) {
|
||||
"modified"
|
||||
Some("modified")
|
||||
} else if status.contains(Status::WT_DELETED) {
|
||||
"deleted"
|
||||
Some("deleted")
|
||||
} else if status.contains(Status::WT_RENAMED) {
|
||||
"renamed"
|
||||
Some("renamed")
|
||||
} else if status.contains(Status::INDEX_NEW) {
|
||||
"staged_new"
|
||||
Some("staged_new")
|
||||
} else if status.contains(Status::INDEX_MODIFIED) {
|
||||
"staged_modified"
|
||||
Some("staged_modified")
|
||||
} else if status.contains(Status::INDEX_DELETED) {
|
||||
"staged_deleted"
|
||||
Some("staged_deleted")
|
||||
} else if status.contains(Status::IGNORED) {
|
||||
"ignored"
|
||||
Some("ignored")
|
||||
} else if status.contains(Status::CURRENT) || status.is_empty() {
|
||||
"clean"
|
||||
Some("clean")
|
||||
} else {
|
||||
"unknown"
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
format_git_status_opt(status).unwrap_or("unknown")
|
||||
}
|
||||
|
||||
+1099
-253
File diff suppressed because it is too large
Load Diff
+136
-24
@@ -1,42 +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 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::{FuzzySearchOptions, ScanProgress};
|
||||
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
|
||||
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::*;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Shared logging utilities for FFF crates.
|
||||
//!
|
||||
//! Provides file-based tracing initialization and a panic hook that writes
|
||||
//! to both stderr and a fallback log file.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install panic hook that writes to both stderr and a fallback file.
|
||||
/// This is called separately from init_tracing to ensure panics are always logged.
|
||||
pub fn install_panic_hook() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("=================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
}
|
||||
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Parse a log level string into a `tracing::Level`.
|
||||
///
|
||||
/// Accepts "trace", "debug", "info", "warn", "error" (case-insensitive).
|
||||
/// Returns `tracing::Level::INFO` for unrecognised values.
|
||||
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
|
||||
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
Some("debug") => tracing::Level::DEBUG,
|
||||
Some("info") => tracing::Level::INFO,
|
||||
Some("warn") => tracing::Level::WARN,
|
||||
Some("error") => tracing::Level::ERROR,
|
||||
_ => tracing::Level::INFO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize tracing with a single log file.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist, truncates the log file,
|
||||
/// and sets up a non-blocking file appender with structured formatting.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true) // creates a new file on every setup
|
||||
.open(log_path)?;
|
||||
|
||||
let level = parse_log_level(log_level);
|
||||
|
||||
TRACING_INITIALIZED.get_or_init(|| {
|
||||
let (non_blocking_appender, guard) = non_blocking(file_appender);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF tracing initialized with log file: {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
}
|
||||
@@ -1,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 {
|
||||
|
||||
@@ -61,10 +61,13 @@ 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
|
||||
opts.max_dbs(16); // Allow up to 16 databases per environment
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
|
||||
@@ -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
|
||||
@@ -172,7 +167,6 @@ pub fn match_and_score_files<'a>(
|
||||
let query_contains_path_separator = fuzzy_parts.iter().any(|p| p.contains(MAIN_SEPARATOR));
|
||||
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(context.max_typos),
|
||||
sort: false,
|
||||
scoring: Scoring {
|
||||
@@ -230,6 +224,14 @@ pub fn match_and_score_files<'a>(
|
||||
|
||||
let mut base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
|
||||
|
||||
// Give modified/dirty files a 15% boost to make them appear higher in results
|
||||
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
|
||||
base_score * 15 / 100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let distance_penalty =
|
||||
calculate_distance_penalty(context.current_file, &file.relative_path);
|
||||
|
||||
@@ -279,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 {
|
||||
@@ -296,6 +299,7 @@ pub fn match_and_score_files<'a>(
|
||||
|
||||
let total = base_score
|
||||
.saturating_add(frecency_boost)
|
||||
.saturating_add(git_status_boost)
|
||||
.saturating_add(distance_penalty)
|
||||
.saturating_add(filename_bonus)
|
||||
.saturating_add(current_file_penalty)
|
||||
@@ -312,12 +316,14 @@ pub fn match_and_score_files<'a>(
|
||||
0
|
||||
},
|
||||
frecency_boost,
|
||||
git_status_boost,
|
||||
distance_penalty,
|
||||
combo_match_boost,
|
||||
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
|
||||
match_type: match filename_match {
|
||||
Some(filename_match) if filename_match.exact => "exact_filename",
|
||||
Some(_) => "fuzzy_filename",
|
||||
None if path_match.exact => "exact_path",
|
||||
None => "fuzzy_path",
|
||||
},
|
||||
};
|
||||
@@ -363,9 +369,18 @@ pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
let total_frecency_score = file.access_frecency_score as i32
|
||||
+ (file.modification_frecency_score as i32).saturating_mul(4);
|
||||
|
||||
// Give modified/dirty files a boost even in frecency-only mode
|
||||
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
|
||||
total_frecency_score * 15 / 100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let current_file_penalty =
|
||||
calculate_current_file_penalty(file, total_frecency_score, context);
|
||||
let total = total_frecency_score.saturating_add(current_file_penalty);
|
||||
let total = total_frecency_score
|
||||
.saturating_add(git_status_boost)
|
||||
.saturating_add(current_file_penalty);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
@@ -376,6 +391,7 @@ pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
combo_match_boost: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
git_status_boost,
|
||||
exact_match: false,
|
||||
match_type: "frecency",
|
||||
};
|
||||
@@ -482,10 +498,11 @@ 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) {
|
||||
let file_name = path.split('/').last().unwrap_or(path).to_string();
|
||||
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
|
||||
let file = FileItem::new_raw(
|
||||
PathBuf::from(path),
|
||||
path.to_string(),
|
||||
@@ -503,6 +520,7 @@ mod tests {
|
||||
special_filename_bonus: 0,
|
||||
current_file_penalty: 0,
|
||||
frecency_boost: 0,
|
||||
git_status_boost: 0,
|
||||
exact_match: false,
|
||||
match_type: "test",
|
||||
combo_match_boost: 0,
|
||||
@@ -532,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,
|
||||
@@ -568,7 +588,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_partial_sort_with_same_scores() {
|
||||
// Test tiebreaker with modified time
|
||||
let test_data = vec![
|
||||
let test_data = [
|
||||
create_test_file("file1.rs", 100, 5000), // Same score, older
|
||||
create_test_file("file2.rs", 100, 8000), // Same score, newer
|
||||
create_test_file("file3.rs", 100, 3000), // Same score, oldest
|
||||
@@ -581,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,
|
||||
@@ -617,7 +639,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_no_partial_sort_for_small_results() {
|
||||
// When results.len() <= threshold, should use regular sort
|
||||
let test_data = vec![
|
||||
let test_data = [
|
||||
create_test_file("file1.rs", 100, 1000),
|
||||
create_test_file("file2.rs", 200, 2000),
|
||||
create_test_file("file3.rs", 50, 3000),
|
||||
@@ -628,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,
|
||||
@@ -666,7 +690,6 @@ mod multi_part_tests {
|
||||
|
||||
// Test with max_typos = 2 (safe for short needles)
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
@@ -698,7 +721,6 @@ mod multi_part_tests {
|
||||
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs".to_lowercase();
|
||||
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use memmap2::Mmap;
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
|
||||
/// A single indexed file with metadata, frecency scores, and lazy mmap.
|
||||
/// Cached file contents — mmap on Unix, heap buffer on Windows.
|
||||
///
|
||||
/// The `mmap` field holds the memory-mapped file contents, initialized lazily
|
||||
/// on the first grep access and cached for subsequent searches. The mmap is
|
||||
/// backed by the kernel page cache and automatically reflects file modifications
|
||||
/// — no manual invalidation is needed.
|
||||
/// On Windows, memory-mapped files hold the file handle open and prevent
|
||||
/// editors from saving (writing/replacing) those files. Reading into a
|
||||
/// `Vec<u8>` releases the handle immediately after the read completes.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)] // variants are conditionally used per platform
|
||||
enum FileContent {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
Mmap(memmap2::Mmap),
|
||||
#[cfg(target_os = "windows")]
|
||||
Buffer(Vec<u8>),
|
||||
}
|
||||
|
||||
impl std::ops::Deref for FileContent {
|
||||
type Target = [u8];
|
||||
fn deref(&self) -> &[u8] {
|
||||
match self {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
FileContent::Mmap(m) => m,
|
||||
#[cfg(target_os = "windows")]
|
||||
FileContent::Buffer(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single indexed file with metadata, frecency scores, and lazy content cache.
|
||||
///
|
||||
/// File contents are initialized lazily on the first grep access and cached for
|
||||
/// subsequent searches. On Unix, uses mmap backed by the kernel page cache. On
|
||||
/// Windows, reads into a heap buffer to avoid holding file handles open.
|
||||
///
|
||||
/// Thread-safety: `OnceLock` provides lock-free reads after initialization.
|
||||
/// Each file is only searched by one rayon worker at a time via `par_iter`.
|
||||
@@ -30,10 +53,9 @@ pub struct FileItem {
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: Option<git2::Status>,
|
||||
pub is_binary: bool,
|
||||
/// Lazily-initialized memory-mapped file contents for grep.
|
||||
/// Lazily-initialized file contents for grep.
|
||||
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
|
||||
/// Automatically reflects file changes via the kernel page cache.
|
||||
mmap: OnceLock<Mmap>,
|
||||
content: OnceLock<FileContent>,
|
||||
}
|
||||
|
||||
impl Clone for FileItem {
|
||||
@@ -51,8 +73,8 @@ impl Clone for FileItem {
|
||||
total_frecency_score: self.total_frecency_score,
|
||||
git_status: self.git_status,
|
||||
is_binary: self.is_binary,
|
||||
// Don't clone the mmap — the clone lazily re-creates it on demand
|
||||
mmap: OnceLock::new(),
|
||||
// Don't clone the content — the clone lazily re-creates it on demand
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,46 +105,65 @@ impl FileItem {
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
is_binary,
|
||||
mmap: OnceLock::new(),
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
|
||||
/// Invalidate the cached content so the next `get_content()` call creates a fresh one.
|
||||
///
|
||||
/// Call this when the background watcher detects that the file has been modified.
|
||||
/// While the kernel page cache reflects content changes automatically, a file
|
||||
/// that is truncated (made smaller) while mapped can cause SIGBUS if the search
|
||||
/// accesses pages beyond the new file size. Invalidating the mmap ensures a
|
||||
/// fresh mapping with the correct size is created on the next access.
|
||||
/// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
|
||||
/// the stale buffer simply won't reflect the new contents. In both cases,
|
||||
/// invalidating ensures a fresh read on the next access.
|
||||
pub fn invalidate_mmap(&mut self) {
|
||||
self.mmap = OnceLock::new();
|
||||
self.content = OnceLock::new();
|
||||
}
|
||||
|
||||
/// Get the cached mmap or lazily create it. Returns `None` if the file
|
||||
/// is too large, empty, or can't be opened/mapped.
|
||||
/// Get the cached file contents or lazily load them. Returns `None` if the
|
||||
/// file is too large, empty, or can't be opened.
|
||||
///
|
||||
/// After the first call, this is lock-free (just an atomic load + pointer deref).
|
||||
/// The mmap is backed by the kernel page cache and automatically reflects
|
||||
/// file modifications — no manual invalidation is needed.
|
||||
/// On Unix, uses mmap backed by the kernel page cache. On Windows, reads into
|
||||
/// a heap buffer so the file handle is released immediately.
|
||||
#[inline]
|
||||
pub fn get_mmap(&self) -> Option<&Mmap> {
|
||||
if let Some(mmap) = self.mmap.get() {
|
||||
return Some(mmap);
|
||||
pub fn get_content(&self) -> Option<&[u8]> {
|
||||
if let Some(content) = self.content.get() {
|
||||
return Some(content);
|
||||
}
|
||||
|
||||
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&self.path).ok()?;
|
||||
let content = load_file_content(&self.path)?;
|
||||
|
||||
// If another thread raced us, OnceLock discards ours and returns theirs.
|
||||
Some(self.content.get_or_init(|| content))
|
||||
}
|
||||
|
||||
/// Backward-compatible alias for `get_content`.
|
||||
#[inline]
|
||||
pub fn get_mmap(&self) -> Option<&[u8]> {
|
||||
self.get_content()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load file contents: mmap on Unix, heap buffer on Windows.
|
||||
fn load_file_content(path: &Path) -> Option<FileContent> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
// SAFETY: The mmap is backed by the kernel page cache and automatically
|
||||
// reflects file modifications. The only risk is SIGBUS if the file is
|
||||
// truncated while mapped
|
||||
let mmap = unsafe { Mmap::map(&file) }.ok()?;
|
||||
// truncated while mapped.
|
||||
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
|
||||
Some(FileContent::Mmap(mmap))
|
||||
}
|
||||
|
||||
// If another thread raced us, OnceLock discards our mmap and returns theirs.
|
||||
// This is fine — the duplicate mmap is just dropped.
|
||||
Some(self.mmap.get_or_init(|| mmap))
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let data = std::fs::read(path).ok()?;
|
||||
Some(FileContent::Buffer(data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,13 +189,14 @@ impl Constrainable for FileItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Score {
|
||||
pub total: i32,
|
||||
pub base_score: i32,
|
||||
pub filename_bonus: i32,
|
||||
pub special_filename_bonus: i32,
|
||||
pub frecency_boost: i32,
|
||||
pub git_status_boost: i32,
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
@@ -168,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -25,6 +25,9 @@ fn plain_opts() -> GrepSearchOptions {
|
||||
page_limit: 200,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +41,9 @@ fn regex_opts() -> GrepSearchOptions {
|
||||
page_limit: 200,
|
||||
mode: GrepMode::Regex,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +57,9 @@ fn fuzzy_opts() -> GrepSearchOptions {
|
||||
page_limit: 200,
|
||||
mode: GrepMode::Fuzzy,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,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);
|
||||
@@ -80,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(),
|
||||
@@ -99,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(),
|
||||
@@ -119,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);
|
||||
}
|
||||
@@ -140,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(),
|
||||
@@ -159,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);
|
||||
}
|
||||
@@ -173,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);
|
||||
}
|
||||
|
||||
@@ -186,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
|
||||
@@ -198,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];
|
||||
@@ -215,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
|
||||
@@ -236,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);
|
||||
@@ -255,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(),
|
||||
@@ -276,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
|
||||
@@ -318,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();
|
||||
@@ -368,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);
|
||||
@@ -387,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);
|
||||
@@ -404,7 +430,8 @@ fn regex_basic_pattern() {
|
||||
"foo123\nbar456\nbaz789\nfoo_bar\n",
|
||||
)];
|
||||
|
||||
let result = grep_search(&files, "foo\\d+", None, ®ex_opts());
|
||||
let parsed = parse_grep_query("foo\\d+");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert_eq!(result.matches[0].line_number, 1);
|
||||
@@ -417,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, ®ex_opts());
|
||||
let parsed = parse_grep_query("foo(bar|baz)");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 2);
|
||||
let contents: Vec<&str> = result
|
||||
@@ -439,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, ®ex_opts());
|
||||
let parsed = parse_grep_query("v1.0");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -457,7 +486,8 @@ fn regex_alternation() {
|
||||
"apple\nbanana\ncherry\napricot\n",
|
||||
)];
|
||||
|
||||
let result = grep_search(&files, "apple|cherry", None, ®ex_opts());
|
||||
let parsed = parse_grep_query("apple|cherry");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 2);
|
||||
let lines: Vec<u64> = result.matches.iter().map(|m| m.line_number).collect();
|
||||
@@ -474,7 +504,8 @@ fn regex_character_class() {
|
||||
"cat\ncut\ncot\ncit\ncxt\n",
|
||||
)];
|
||||
|
||||
let result = grep_search(&files, "c[aou]t", None, ®ex_opts());
|
||||
let parsed = parse_grep_query("c[aou]t");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 3);
|
||||
let contents: Vec<&str> = result
|
||||
@@ -496,7 +527,8 @@ fn regex_quantifiers() {
|
||||
"fo\nfoo\nfooo\nfoooo\nbar\n",
|
||||
)];
|
||||
|
||||
let result = grep_search(&files, "fo{2,3}", None, ®ex_opts());
|
||||
let parsed = parse_grep_query("fo{2,3}");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 3, "should match foo, fooo, foooo");
|
||||
}
|
||||
@@ -510,18 +542,42 @@ fn regex_anchors() {
|
||||
"start of line\nmiddle start end\nend of line\n",
|
||||
)];
|
||||
|
||||
let result = grep_search(&files, "^start", None, ®ex_opts());
|
||||
let parsed = parse_grep_query("^start");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert_eq!(result.matches[0].line_number, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_anchors_multiword() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let files = vec![create_file(
|
||||
tmp.path(),
|
||||
"test.c",
|
||||
"int ff_function(void);\nstatic int ff_other(void);\nint main(void);\nint ff_another(void);\n",
|
||||
)];
|
||||
|
||||
// ^int ff_ should match lines starting with "int ff_"
|
||||
let parsed = parse_grep_query("^int ff_");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
2,
|
||||
"should match 2 lines starting with 'int ff_'"
|
||||
);
|
||||
assert!(result.matches[0].line_content.contains("ff_function"));
|
||||
assert!(result.matches[1].line_content.contains("ff_another"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_highlight_offsets_variable_length() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let files = vec![create_file(tmp.path(), "a.txt", "aab aaab aaaab\n")];
|
||||
|
||||
let result = grep_search(&files, "a+b", None, ®ex_opts());
|
||||
let parsed = parse_grep_query("a+b");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
let m = &result.matches[0];
|
||||
@@ -543,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, ®ex_opts());
|
||||
let parsed = parse_grep_query("name(");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
// Fallback to literal: finds "name(" in "call name(arg)"
|
||||
assert_eq!(
|
||||
@@ -558,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, ®ex_opts());
|
||||
let parsed2 = parse_grep_query("zzz(");
|
||||
let result2 = grep_search(&files, &parsed2, ®ex_opts());
|
||||
assert_eq!(result2.matches.len(), 0);
|
||||
assert!(result2.regex_fallback_error.is_some());
|
||||
}
|
||||
@@ -573,11 +631,13 @@ fn regex_smart_case() {
|
||||
)];
|
||||
|
||||
// Lowercase query → case-insensitive
|
||||
let result_lower = grep_search(&files, "foo", None, ®ex_opts());
|
||||
let parsed_lower = parse_grep_query("foo");
|
||||
let result_lower = grep_search(&files, &parsed_lower, ®ex_opts());
|
||||
assert_eq!(result_lower.matches.len(), 3);
|
||||
|
||||
// Query with uppercase → case-sensitive
|
||||
let result_upper = grep_search(&files, "Foo", None, ®ex_opts());
|
||||
let parsed_upper = parse_grep_query("Foo");
|
||||
let result_upper = grep_search(&files, &parsed_upper, ®ex_opts());
|
||||
assert_eq!(result_upper.matches.len(), 1);
|
||||
}
|
||||
|
||||
@@ -598,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, ®ex_opts());
|
||||
let parsed = parse_grep_query("fn \\w+\\(\\)");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
// Should match: fn main(), fn helper(), fn test_one(), fn test_two()
|
||||
assert_eq!(result.matches.len(), 4);
|
||||
@@ -616,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, ®ex_opts());
|
||||
let parsed = parse_grep_query("hello");
|
||||
let plain_result = grep_search(&files, &parsed, &plain_opts());
|
||||
let regex_result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(plain_result.matches.len(), regex_result.matches.len());
|
||||
for (p, r) in plain_result.matches.iter().zip(regex_result.matches.iter()) {
|
||||
@@ -636,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, ®ex_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, ®ex_opts());
|
||||
|
||||
// Plain text should find "$100" literally
|
||||
assert_eq!(plain_result.matches.len(), 1);
|
||||
@@ -659,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, &plain_opts());
|
||||
let result = grep_search(&files, &parsed, &plain_opts());
|
||||
|
||||
// Should only search .rs files
|
||||
for file in &result.files {
|
||||
@@ -686,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(),
|
||||
@@ -708,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, &plain_opts());
|
||||
let result_filter = grep_search(&files, &parsed, &plain_opts());
|
||||
assert_eq!(
|
||||
result_filter.files.len(),
|
||||
1,
|
||||
@@ -717,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, &plain_opts());
|
||||
let result_literal = grep_search(&files, &parsed_escaped, &plain_opts());
|
||||
assert_eq!(
|
||||
result_literal.matches.len(),
|
||||
2,
|
||||
@@ -734,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,
|
||||
@@ -752,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"));
|
||||
}
|
||||
@@ -767,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, &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/"));
|
||||
@@ -786,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, &plain_opts());
|
||||
let result = grep_search(&files, &parsed, &plain_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -812,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, &plain_opts());
|
||||
let result = grep_search(&files, &parsed, &plain_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -838,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, &plain_opts());
|
||||
let result = grep_search(&files, &parsed, &plain_opts());
|
||||
|
||||
// "tests/helper.rs" contains "test" in path, should be excluded
|
||||
assert_eq!(
|
||||
@@ -868,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);
|
||||
}
|
||||
@@ -878,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);
|
||||
@@ -893,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);
|
||||
}
|
||||
@@ -908,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)
|
||||
@@ -928,7 +1000,8 @@ fn regex_word_boundary() {
|
||||
"foo\nfoobar\nbarfoo\nfoo_baz\n",
|
||||
)];
|
||||
|
||||
let result = grep_search(&files, "\\bfoo\\b", None, ®ex_opts());
|
||||
let parsed = parse_grep_query("\\bfoo\\b");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -947,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(),
|
||||
@@ -965,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(),
|
||||
@@ -980,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, ®ex_opts());
|
||||
let parsed = parse_grep_query("colou?r");
|
||||
let result = grep_search(&files, &parsed, ®ex_opts());
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -1000,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(),
|
||||
@@ -1021,10 +1098,11 @@ 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.len() >= 1,
|
||||
!result.matches.is_empty(),
|
||||
"fuzzy should find 'mutex' in 'mutex_lock'"
|
||||
);
|
||||
assert!(result.matches[0].line_content.contains("mutex_lock"));
|
||||
@@ -1035,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];
|
||||
@@ -1059,11 +1138,12 @@ 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
|
||||
assert!(result.matches.len() >= 1);
|
||||
assert!(!result.matches.is_empty());
|
||||
assert!(result.matches[0].line_content.contains("régulière"));
|
||||
}
|
||||
|
||||
@@ -1072,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);
|
||||
@@ -1088,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, &fuzzy_opts());
|
||||
let result = grep_search(&files, &parsed, &fuzzy_opts());
|
||||
|
||||
// Should only search .rs files
|
||||
for file in &result.files {
|
||||
@@ -1113,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
|
||||
@@ -1148,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(),
|
||||
@@ -1168,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!(
|
||||
@@ -1197,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(),
|
||||
@@ -1216,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];
|
||||
@@ -1237,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]
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "fff-mcp"
|
||||
version = "0.2.4"
|
||||
edition = "2024"
|
||||
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "fff-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["zlob"]
|
||||
zlob = ["fff/zlob"]
|
||||
|
||||
[dependencies]
|
||||
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"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
fn main() {
|
||||
// Embed the git commit hash at build time for update checking.
|
||||
let hash = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!("cargo:rustc-env=FFF_GIT_HASH={}", hash);
|
||||
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../../.git/refs/");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Cursor store for grep pagination.
|
||||
//!
|
||||
//! Maintains an in-memory map of opaque cursor IDs to file offsets.
|
||||
//! Cursors are evicted LRU-style when the store exceeds capacity.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
const MAX_CURSORS: usize = 20;
|
||||
|
||||
/// Stores cursor state for paginated grep results.
|
||||
pub struct CursorStore {
|
||||
counter: u64,
|
||||
/// Map from cursor ID string → file offset for next page.
|
||||
cursors: HashMap<String, usize>,
|
||||
/// Insertion order for LRU eviction.
|
||||
insertion_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl CursorStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
counter: 0,
|
||||
cursors: HashMap::new(),
|
||||
insertion_order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a cursor and return its opaque ID string.
|
||||
pub fn store(&mut self, file_offset: usize) -> String {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
let id = self.counter.to_string();
|
||||
|
||||
self.cursors.insert(id.clone(), file_offset);
|
||||
self.insertion_order.push_back(id.clone());
|
||||
|
||||
// Evict oldest cursors
|
||||
while self.cursors.len() > MAX_CURSORS {
|
||||
if let Some(oldest) = self.insertion_order.pop_front() {
|
||||
self.cursors.remove(&oldest);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Retrieve the file offset for a cursor ID.
|
||||
pub fn get(&self, id: &str) -> Option<usize> {
|
||||
self.cursors.get(id).copied()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
//!
|
||||
//! Drop-in replacement for AI code assistant file search tools (Glob/Grep).
|
||||
//! Provides frecency-ranked, fuzzy-matched, git-aware file finding and
|
||||
//! code search via the Model Context Protocol (MCP).
|
||||
//!
|
||||
//! Uses `fff-core` directly (zero FFI overhead) for all search operations.
|
||||
|
||||
mod cursor;
|
||||
mod output;
|
||||
mod server;
|
||||
mod update_check;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use clap::Parser;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
use fff::{FFFMode, SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use mimalloc::MiMalloc;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use server::FffServer;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
pub const MCP_INSTRUCTIONS: &str = concat!(
|
||||
"FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n",
|
||||
"\n",
|
||||
"## Which Tool Should I Use?\n",
|
||||
"\n",
|
||||
"- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n",
|
||||
"- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n",
|
||||
"- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n",
|
||||
"\n",
|
||||
"## Core Rules\n",
|
||||
"\n",
|
||||
"### 1. Search BARE IDENTIFIERS only\n",
|
||||
"Grep matches single lines. Search for ONE identifier per query:\n",
|
||||
" + 'InProgressQuote' -> finds definition + all usages\n",
|
||||
" + 'ActorAuth' -> finds enum, struct, all call sites\n",
|
||||
" x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n",
|
||||
" x 'ctx.data::<ActorAuth>' -> code syntax, too specific, 0 results\n",
|
||||
" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n",
|
||||
" x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n",
|
||||
"\n",
|
||||
"### 2. NEVER use regex unless you truly need alternation\n",
|
||||
"Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n",
|
||||
"If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n",
|
||||
"\n",
|
||||
"### 3. Stop searching after 2 greps -- READ the code\n",
|
||||
"After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n",
|
||||
"Do NOT keep grepping with variations. More greps != better understanding.\n",
|
||||
"\n",
|
||||
"### 4. Use multi_grep for multiple identifiers\n",
|
||||
"When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n",
|
||||
" + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n",
|
||||
" x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n",
|
||||
"\n",
|
||||
"## Workflow\n",
|
||||
"\n",
|
||||
"**Have a specific name?** -> grep the bare identifier.\n",
|
||||
"**Need multiple name variants?** -> multi_grep with all variants in one call.\n",
|
||||
"**Exploring a topic / finding files?** -> find_files.\n",
|
||||
"**Got results?** -> Read the top file. Don't grep again.\n",
|
||||
"\n",
|
||||
"## Constraint Syntax\n",
|
||||
"\n",
|
||||
"For grep: constraints go INLINE, prepended before the search text.\n",
|
||||
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
|
||||
"\n",
|
||||
"Constraints MUST match one of these formats:\n",
|
||||
" Extension: '*.rs', '*.{ts,tsx}'\n",
|
||||
" Directory: 'src/', 'quotes/'\n",
|
||||
" Filename: 'schema.rs', 'src/main.rs'\n",
|
||||
" Exclude: '!test/', '!*.spec.ts'\n",
|
||||
"\n",
|
||||
"! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
|
||||
" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n",
|
||||
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
|
||||
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
|
||||
"\n",
|
||||
"Prefer broad constraints:\n",
|
||||
" + '*.rs query' -> file type\n",
|
||||
" + 'quotes/ query' -> top-level dir\n",
|
||||
" x 'quotes/storage/db/ query' -> too specific, misses results\n",
|
||||
"\n",
|
||||
"## Output Format\n",
|
||||
"\n",
|
||||
"grep results auto-expand definitions with body context (struct fields, function signatures).\n",
|
||||
"This often provides enough information WITHOUT a follow-up Read call.\n",
|
||||
"Lines marked with | are definition body context. [def] marks definition files.\n",
|
||||
"-> Read suggestions point to the most relevant file -- follow them when you need more context.\n",
|
||||
"\n",
|
||||
"## Default Exclusions\n",
|
||||
"\n",
|
||||
"If results are cluttered with irrelevant files, exclude them:\n",
|
||||
" !tests/ - exclude tests directory\n",
|
||||
" !*.spec.ts - exclude test files\n",
|
||||
" !generated/ - exclude generated code",
|
||||
);
|
||||
|
||||
/// FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
|
||||
struct Args {
|
||||
/// Base directory to index. Defaults to the current working directory.
|
||||
#[arg(value_name = "PATH")]
|
||||
base_path: Option<String>,
|
||||
|
||||
/// Path to the frecency database.
|
||||
#[arg(long = "frecency-db")]
|
||||
frecency_db_path: Option<String>,
|
||||
|
||||
/// Path to the query history database.
|
||||
#[arg(long = "history-db")]
|
||||
#[allow(dead_code)]
|
||||
history_db_path: Option<String>,
|
||||
|
||||
/// Path to the log file.
|
||||
#[arg(long = "log-file")]
|
||||
log_file: Option<String>,
|
||||
|
||||
/// Log level (e.g. trace, debug, info, warn, error).
|
||||
#[arg(long = "log-level")]
|
||||
log_level: Option<String>,
|
||||
|
||||
/// Disable automatic update checks on startup.
|
||||
#[arg(long = "no-update-check")]
|
||||
no_update_check: bool,
|
||||
}
|
||||
|
||||
/// Resolve default paths for frecency db, history db, and log file.
|
||||
/// Shares Neovim's standard data locations when they exist so the MCP
|
||||
/// server and fff.nvim plugin use the same databases.
|
||||
fn resolve_defaults(args: &mut Args) {
|
||||
let home = dirs_home();
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
|
||||
let nvim_cache_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.cache/nvim", home)
|
||||
};
|
||||
let nvim_data_dir = if is_windows {
|
||||
format!("{}\\AppData\\Local\\nvim-data", home)
|
||||
} else {
|
||||
format!("{}/.local/share/nvim", home)
|
||||
};
|
||||
|
||||
let use_nvim_paths = std::path::Path::new(&nvim_cache_dir).exists()
|
||||
|| std::path::Path::new(&nvim_data_dir).exists();
|
||||
|
||||
if args.frecency_db_path.is_none() {
|
||||
args.frecency_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_nvim", nvim_cache_dir)
|
||||
} else {
|
||||
format!("{}/.fff/frecency.mdb", home)
|
||||
});
|
||||
}
|
||||
if args.history_db_path.is_none() {
|
||||
args.history_db_path = Some(if use_nvim_paths {
|
||||
format!("{}/fff_queries", nvim_data_dir)
|
||||
} else {
|
||||
format!("{}/.fff/history.mdb", home)
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure parent directories exist for database paths
|
||||
for path in [&args.frecency_db_path, &args.history_db_path]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
if args.log_file.is_none() {
|
||||
args.log_file = Some(if is_windows {
|
||||
format!("{}\\AppData\\Local\\fff_mcp.log", home)
|
||||
} else {
|
||||
format!("{}/.cache/fff_mcp.log", home)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_home() -> String {
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| "/tmp".to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = Args::parse();
|
||||
resolve_defaults(&mut args);
|
||||
|
||||
let log_file = args.log_file.as_deref().unwrap_or("");
|
||||
if let Err(e) = fff::log::init_tracing(log_file, args.log_level.as_deref()) {
|
||||
eprintln!("Warning: Failed to init tracing: {}", e);
|
||||
}
|
||||
|
||||
let base_path = args.base_path.unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
if Repository::discover(&base_path).is_err() {
|
||||
tracing::error!("MCP server must be run within a Git repository");
|
||||
return Err(format!("Not a Git repository: {}", base_path).into());
|
||||
}
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
match FrecencyTracker::new(&frecency_db_path, false) {
|
||||
Ok(tracker) => {
|
||||
if let Ok(mut guard) = shared_frecency.write() {
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
|
||||
let _ =
|
||||
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to init frecency db: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize file picker (spawns background scan + watcher)
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
true, // warmup_mmap_cache
|
||||
FFFMode::Ai,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
.map_err(|e| format!("Failed to init file picker: {}", e))?;
|
||||
|
||||
if !args.no_update_check {
|
||||
update_check::spawn_update_check();
|
||||
}
|
||||
|
||||
// Create and start the MCP server
|
||||
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
|
||||
|
||||
// Wait for initial scan in background — don't block server startup
|
||||
let picker_clone_for_scan = Arc::clone(&shared_picker);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
let is_scanning = picker_clone_for_scan
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|p| p.is_scan_active()))
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_scanning {
|
||||
tracing::info!("Initial scan completed in {:?}", start.elapsed());
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
|
||||
let service = server
|
||||
.serve(stdio())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start MCP server: {}", e))?;
|
||||
|
||||
let picker_for_shutdown = shared_picker.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
if let Ok(mut guard) = picker_for_shutdown.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
std::process::exit(0);
|
||||
});
|
||||
|
||||
service.waiting().await?;
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
//! Output formatting for MCP grep/search results.
|
||||
//!
|
||||
//! Port of `packages/fff-mcp/src/output.ts` — token-efficient formatting
|
||||
//! with definition auto-expansion, frecency/git annotations, and Read suggestions.
|
||||
|
||||
use fff::GrepMatch;
|
||||
use fff::git::format_git_status_opt;
|
||||
use fff::grep::is_import_line;
|
||||
use fff::types::FileItem;
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
|
||||
/// Frecency score → single-token word. `None` for low-scoring files.
|
||||
fn frecency_word(score: i64) -> Option<&'static str> {
|
||||
if score >= 100 {
|
||||
Some("hot")
|
||||
} else if score >= 50 {
|
||||
Some("warm")
|
||||
} else if score >= 10 {
|
||||
Some("frequent")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build " - hot git:modified" style suffix. Empty when nothing to report.
|
||||
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i64) -> String {
|
||||
match (
|
||||
frecency_word(frecency_score),
|
||||
format_git_status_opt(git_status),
|
||||
) {
|
||||
(Some(f), Some(g)) => format!(" - {f} git:{g}"),
|
||||
(Some(f), None) => format!(" - {f}"),
|
||||
(None, Some(g)) => format!(" git:{g}"),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutputMode {
|
||||
Content,
|
||||
FilesWithMatches,
|
||||
Count,
|
||||
Usage,
|
||||
}
|
||||
|
||||
impl OutputMode {
|
||||
pub fn new(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
Some("files_with_matches") => Self::FilesWithMatches,
|
||||
Some("count") => Self::Count,
|
||||
Some("usage") => Self::Usage,
|
||||
_ => Self::Content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LARGE_FILE_BYTES: u64 = 20_000;
|
||||
|
||||
/// Tag for large files — nudges model to use offset/limit when reading.
|
||||
fn size_tag(bytes: u64) -> String {
|
||||
if bytes < LARGE_FILE_BYTES {
|
||||
String::new()
|
||||
} else {
|
||||
let kb = (bytes + 512) / 1024; // round
|
||||
format!(" ({}KB - use offset to read relevant section)", kb)
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_PREVIEW: usize = 120;
|
||||
const MAX_LINE_LEN: usize = 180;
|
||||
/// Max context lines to show when auto-expanding the first definition
|
||||
const MAX_DEF_EXPAND_FIRST: usize = 8;
|
||||
/// Max context lines for subsequent definitions
|
||||
const MAX_DEF_EXPAND: usize = 5;
|
||||
/// Max context lines for non-definition first match in small result sets
|
||||
const MAX_FIRST_MATCH_EXPAND: usize = 8;
|
||||
|
||||
fn trauncate_line_for_ai(
|
||||
line: &str,
|
||||
match_ranges: Option<&[(u32, u32)]>,
|
||||
max_len: usize,
|
||||
) -> String {
|
||||
// Strip leading/trailing whitespace to save tokens — the LLM has file:line for location.
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let strip_offset = line.len() - line.trim_start().len();
|
||||
|
||||
if trimmed.len() <= max_len {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
// Adjust match ranges for the stripped leading whitespace
|
||||
let adjusted: Vec<(u32, u32)>;
|
||||
let ranges = match match_ranges {
|
||||
Some(r) if strip_offset > 0 => {
|
||||
let off = strip_offset as u32;
|
||||
adjusted = r
|
||||
.iter()
|
||||
.map(|&(s, e)| (s.saturating_sub(off), e.saturating_sub(off)))
|
||||
.collect();
|
||||
Some(adjusted.as_slice())
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
|
||||
// Use first match range to center the window
|
||||
if let Some(ranges) = ranges
|
||||
&& let Some(&(match_start, match_end)) = ranges.first()
|
||||
{
|
||||
let match_start = match_start as usize;
|
||||
let match_end = match_end as usize;
|
||||
let match_len = match_end.saturating_sub(match_start);
|
||||
|
||||
let budget = max_len.saturating_sub(match_len);
|
||||
let before = budget / 3;
|
||||
let after = budget - before;
|
||||
|
||||
let win_start = match_start.saturating_sub(before);
|
||||
let win_end = (match_end + after).min(trimmed.len());
|
||||
|
||||
// Clamp to char boundaries
|
||||
let win_start = floor_char_boundary(trimmed, win_start);
|
||||
let win_end = ceil_char_boundary(trimmed, win_end);
|
||||
|
||||
let mut result = trimmed[win_start..win_end].to_string();
|
||||
if win_start > 0 {
|
||||
result.insert(0, '…');
|
||||
}
|
||||
if win_end < trimmed.len() {
|
||||
result.push('…');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// No match ranges — truncate from start
|
||||
let end = ceil_char_boundary(trimmed, max_len);
|
||||
format!("{}…", &trimmed[..end])
|
||||
}
|
||||
|
||||
/// Floor to a valid char boundary
|
||||
fn floor_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut i = index;
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Ceil to a valid char boundary
|
||||
fn ceil_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut i = index;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Collected file metadata for the first match per file.
|
||||
struct FileMeta<'a> {
|
||||
file: &'a FileItem,
|
||||
line_number: u64,
|
||||
line_content: String,
|
||||
is_definition: bool,
|
||||
match_ranges: Vec<(u32, u32)>,
|
||||
context_after: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for [`format_grep_results`].
|
||||
///
|
||||
/// Groups the read-only inputs so callers don't juggle 10 positional args.
|
||||
pub struct GrepFormatter<'a> {
|
||||
pub matches: &'a [GrepMatch],
|
||||
pub files: &'a [&'a FileItem],
|
||||
pub total_matched: usize,
|
||||
pub next_file_offset: usize,
|
||||
pub regex_fallback_error: Option<&'a str>,
|
||||
pub output_mode: OutputMode,
|
||||
pub max_results: usize,
|
||||
pub show_context: bool,
|
||||
pub auto_expand_defs: bool,
|
||||
}
|
||||
|
||||
impl GrepFormatter<'_> {
|
||||
pub fn format(&self, cursor_store: &mut CursorStore) -> String {
|
||||
let GrepFormatter {
|
||||
matches,
|
||||
files,
|
||||
total_matched,
|
||||
next_file_offset,
|
||||
regex_fallback_error,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context,
|
||||
auto_expand_defs,
|
||||
} = *self;
|
||||
|
||||
let items = if matches.len() > max_results {
|
||||
&matches[..max_results]
|
||||
} else {
|
||||
matches
|
||||
};
|
||||
|
||||
if output_mode == OutputMode::FilesWithMatches {
|
||||
return format_files_with_matches(
|
||||
items,
|
||||
files,
|
||||
next_file_offset,
|
||||
auto_expand_defs,
|
||||
cursor_store,
|
||||
);
|
||||
}
|
||||
|
||||
if output_mode == OutputMode::Count {
|
||||
return format_count(items, files, next_file_offset, cursor_store);
|
||||
}
|
||||
|
||||
// output_mode == usage
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let unique_files = {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for m in items {
|
||||
seen.insert(m.file_index);
|
||||
}
|
||||
seen.len()
|
||||
};
|
||||
|
||||
let max_output_chars: usize = if output_mode == OutputMode::Usage || unique_files <= 3 {
|
||||
5000
|
||||
} else if unique_files <= 8 {
|
||||
3500
|
||||
} else {
|
||||
2500
|
||||
};
|
||||
|
||||
if let Some(err) = regex_fallback_error {
|
||||
lines.push(format!("! regex failed: {}, using literal match", err));
|
||||
}
|
||||
|
||||
// File overview: collect first match per file
|
||||
let file_preview = collect_file_preview(items, files);
|
||||
let mut content_def_file = "";
|
||||
let mut content_first_file = "";
|
||||
for fm in &file_preview {
|
||||
if content_first_file.is_empty() {
|
||||
content_first_file = &fm.file.relative_path;
|
||||
}
|
||||
if content_def_file.is_empty() && fm.is_definition {
|
||||
content_def_file = &fm.file.relative_path;
|
||||
}
|
||||
}
|
||||
|
||||
let content_suggest = if !content_def_file.is_empty() {
|
||||
content_def_file
|
||||
} else {
|
||||
content_first_file
|
||||
};
|
||||
if !content_suggest.is_empty() {
|
||||
let file_count = file_preview.len();
|
||||
if file_count == 1 {
|
||||
lines.push(format!("→ Read {} (only match)", content_suggest));
|
||||
} else if !content_def_file.is_empty() {
|
||||
lines.push(format!("→ Read {} [def]", content_suggest));
|
||||
} else if file_count <= 3 {
|
||||
lines.push(format!("→ Read {} (best match)", content_suggest));
|
||||
}
|
||||
}
|
||||
|
||||
if total_matched > items.len() {
|
||||
lines.push(format!("{}/{} matches shown", items.len(), total_matched));
|
||||
}
|
||||
|
||||
// Track which files already had a definition expanded
|
||||
let mut def_expanded_files = std::collections::HashSet::new();
|
||||
|
||||
// Detailed content (subject to budget)
|
||||
let mut char_count = 0usize;
|
||||
let mut shown_count = 0usize;
|
||||
let mut current_file = "";
|
||||
|
||||
// Reorder: definitions first, then usages, then imports (when auto-expanding)
|
||||
let sorted_items: Vec<usize> = if auto_expand_defs {
|
||||
let mut indices: Vec<usize> = (0..items.len()).collect();
|
||||
indices.sort_unstable_by_key(|&i| {
|
||||
if items[i].is_definition {
|
||||
0
|
||||
} else if is_import_line(&items[i].line_content) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
|
||||
indices
|
||||
} else {
|
||||
(0..items.len()).collect()
|
||||
};
|
||||
|
||||
for &idx in &sorted_items {
|
||||
let m = &items[idx];
|
||||
let file = files[m.file_index];
|
||||
let mut match_lines: Vec<String> = Vec::new();
|
||||
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
match_lines.push(current_file.to_string());
|
||||
}
|
||||
|
||||
// Skip import-only lines when we already have definitions
|
||||
if auto_expand_defs && is_import_line(&m.line_content) && !def_expanded_files.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Context before (only when explicitly requested)
|
||||
if show_context && !m.context_before.is_empty() {
|
||||
let start_line = m.line_number.saturating_sub(m.context_before.len() as u64);
|
||||
for (i, ctx) in m.context_before.iter().enumerate() {
|
||||
match_lines.push(format!(
|
||||
" {}-{}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Match line
|
||||
match_lines.push(format!(
|
||||
" {}: {}",
|
||||
m.line_number,
|
||||
trauncate_line_for_ai(
|
||||
&m.line_content,
|
||||
Some(m.match_byte_offsets.as_ref()),
|
||||
MAX_LINE_LEN
|
||||
)
|
||||
));
|
||||
|
||||
// Context after (only when explicitly requested via context parameter)
|
||||
if show_context && !m.context_after.is_empty() {
|
||||
let start_line = m.line_number + 1;
|
||||
for (i, ctx) in m.context_after.iter().enumerate() {
|
||||
match_lines.push(format!(
|
||||
" {}-{}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
|
||||
));
|
||||
}
|
||||
match_lines.push("--".to_string());
|
||||
}
|
||||
|
||||
// Auto-expand definitions with body context
|
||||
if auto_expand_defs
|
||||
&& !show_context
|
||||
&& m.is_definition
|
||||
&& !m.context_after.is_empty()
|
||||
&& !def_expanded_files.contains(file.relative_path.as_str())
|
||||
{
|
||||
let expand_limit = if def_expanded_files.is_empty() {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_files.insert(file.relative_path.as_str());
|
||||
let start_line = m.line_number + 1;
|
||||
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
|
||||
if ctx.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
match_lines.push(format!(
|
||||
" {}| {}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let chunk = match_lines.join("\n");
|
||||
if char_count + chunk.len() > max_output_chars && shown_count > 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
char_count += chunk.len();
|
||||
lines.push(chunk);
|
||||
shown_count += 1;
|
||||
}
|
||||
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
lines.push(format!("\ncursor: {}", cursor_id));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_files_with_matches(
|
||||
items: &[GrepMatch],
|
||||
files: &[&FileItem],
|
||||
next_file_offset: usize,
|
||||
auto_expand_defs: bool,
|
||||
cursor_store: &mut CursorStore,
|
||||
) -> String {
|
||||
let file_map = collect_file_preview(items, files);
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let file_count = file_map.len();
|
||||
|
||||
// Find best Read target
|
||||
let mut first_def_file = "";
|
||||
let mut first_file = "";
|
||||
for fm in &file_map {
|
||||
if first_file.is_empty() {
|
||||
first_file = &fm.file.relative_path;
|
||||
}
|
||||
if first_def_file.is_empty() && fm.is_definition {
|
||||
first_def_file = &fm.file.relative_path;
|
||||
}
|
||||
}
|
||||
let suggest_path = if !first_def_file.is_empty() {
|
||||
first_def_file
|
||||
} else {
|
||||
first_file
|
||||
};
|
||||
|
||||
if !suggest_path.is_empty() {
|
||||
if file_count == 1 {
|
||||
lines.push(format!(
|
||||
"→ Read {} (only match — no need to search further)",
|
||||
suggest_path
|
||||
));
|
||||
} else if !first_def_file.is_empty() && file_count <= 5 {
|
||||
lines.push(format!("→ Read {} (definition found)", suggest_path));
|
||||
} else if !first_def_file.is_empty() {
|
||||
lines.push(format!("→ Read {} (definition)", suggest_path));
|
||||
} else if file_count <= 3 {
|
||||
lines.push(format!("→ Read {} (best match)", suggest_path));
|
||||
} else {
|
||||
lines.push(format!("→ Read {}", suggest_path));
|
||||
}
|
||||
}
|
||||
|
||||
let is_small_set = file_count <= 5;
|
||||
let mut def_expanded_count = 0usize;
|
||||
|
||||
for (file_idx, fm) in file_map.iter().enumerate() {
|
||||
let is_def = fm.is_definition;
|
||||
let def_tag = if is_def { " [def]" } else { "" };
|
||||
lines.push(format!(
|
||||
"{}{}{}",
|
||||
fm.file.relative_path,
|
||||
def_tag,
|
||||
size_tag(fm.file.size)
|
||||
));
|
||||
|
||||
// Show preview
|
||||
if !fm.line_content.is_empty() && (is_def || file_idx == 0 || is_small_set) {
|
||||
let ranges_ref: Option<&[(u32, u32)]> = if fm.match_ranges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&fm.match_ranges)
|
||||
};
|
||||
lines.push(format!(
|
||||
" {}: {}",
|
||||
fm.line_number,
|
||||
trauncate_line_for_ai(&fm.line_content, ranges_ref, MAX_PREVIEW)
|
||||
));
|
||||
|
||||
// Auto-expand body context
|
||||
if auto_expand_defs && !fm.context_after.is_empty() {
|
||||
let expand_limit = if is_def {
|
||||
let limit = if def_expanded_count == 0 {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_count += 1;
|
||||
limit
|
||||
} else if is_small_set && file_idx == 0 {
|
||||
MAX_FIRST_MATCH_EXPAND
|
||||
} else if is_small_set {
|
||||
MAX_DEF_EXPAND
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if expand_limit > 0 {
|
||||
let start_line = fm.line_number + 1;
|
||||
for (i, ctx) in fm.context_after.iter().take(expand_limit).enumerate() {
|
||||
if ctx.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
lines.push(format!(
|
||||
" {}| {}",
|
||||
start_line + i as u64,
|
||||
trauncate_line_for_ai(ctx, None, MAX_PREVIEW)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
lines.push(format!("\ncursor: {}", cursor_id));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_count(
|
||||
items: &[GrepMatch],
|
||||
files: &[&FileItem],
|
||||
next_file_offset: usize,
|
||||
cursor_store: &mut CursorStore,
|
||||
) -> String {
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
let mut order: Vec<&str> = Vec::new();
|
||||
for m in items {
|
||||
let path = files[m.file_index].relative_path.as_str();
|
||||
let count = counts.entry(path).or_insert_with(|| {
|
||||
order.push(path);
|
||||
0
|
||||
});
|
||||
*count += 1;
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for path in &order {
|
||||
lines.push(format!("{}: {}", path, counts[*path]));
|
||||
}
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
lines.push(format!("\ncursor: {}", cursor_id));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<FileMeta<'a>> {
|
||||
let mut file_preview: Vec<FileMeta<'a>> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for m in items {
|
||||
let file = files[m.file_index];
|
||||
if seen.insert(&file.relative_path) {
|
||||
file_preview.push(FileMeta {
|
||||
file,
|
||||
line_number: m.line_number,
|
||||
line_content: m.line_content.clone(),
|
||||
is_definition: m.is_definition,
|
||||
match_ranges: m.match_byte_offsets.iter().copied().collect(),
|
||||
context_after: m.context_after.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
file_preview
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trunc_strips_whitespace() {
|
||||
assert_eq!(trauncate_line_for_ai(" foo()", None, 180), "foo()");
|
||||
assert_eq!(trauncate_line_for_ai(" bar ", None, 180), "bar");
|
||||
assert_eq!(trauncate_line_for_ai(" ", None, 180), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trunc_adjusts_match_ranges_after_strip() {
|
||||
// " hello" — match on "hello" at bytes 4..9
|
||||
let line = " hello";
|
||||
let ranges = [(4, 9)];
|
||||
let result = trauncate_line_for_ai(line, Some(&ranges), 180);
|
||||
// After stripping 4 leading spaces, the trimmed line is "hello"
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trunc_long_line_centered() {
|
||||
let line = format!("{}match_here{}", " ".repeat(8), "x".repeat(200));
|
||||
let ranges = [(8u32, 18u32)];
|
||||
let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
|
||||
assert!(result.contains("match_here"));
|
||||
assert!(result.len() <= 55); // budget + ellipsis chars
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
//! FFF MCP server — tool definitions and handlers.
|
||||
//!
|
||||
//! Uses the `rmcp` crate's `#[tool_router]` / `#[tool_handler]` macros
|
||||
//! for declarative tool registration. Each tool method directly calls
|
||||
//! `fff-core` APIs (no C FFI overhead).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use fff::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;
|
||||
use rmcp::model::*;
|
||||
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
|
||||
/// Strip common delimiters and lowercase for fuzzy fallback queries.
|
||||
fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if !matches!(c, ':' | '-' | '_') {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute grep search options from output mode and context settings.
|
||||
fn make_grep_options(
|
||||
output_mode: OutputMode,
|
||||
mode: GrepMode,
|
||||
file_offset: usize,
|
||||
context: Option<usize>,
|
||||
) -> (GrepSearchOptions, bool) {
|
||||
let is_usage = output_mode == OutputMode::Usage;
|
||||
let matches_per_file = match output_mode {
|
||||
OutputMode::FilesWithMatches => 1,
|
||||
_ if is_usage => 8,
|
||||
_ => 10,
|
||||
};
|
||||
let ctx_lines = if is_usage {
|
||||
context.unwrap_or(1)
|
||||
} else {
|
||||
context.unwrap_or(0)
|
||||
};
|
||||
let auto_expand = !is_usage && ctx_lines == 0;
|
||||
let after_ctx = if auto_expand { 8 } else { ctx_lines };
|
||||
|
||||
(
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: matches_per_file,
|
||||
smart_case: true,
|
||||
file_offset,
|
||||
page_limit: 50,
|
||||
mode,
|
||||
time_budget_ms: 0,
|
||||
before_context: ctx_lines,
|
||||
after_context: after_ctx,
|
||||
classify_definitions: true,
|
||||
},
|
||||
auto_expand,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct FindFilesParams {
|
||||
/// Fuzzy search query. Supports path prefixes and glob constraints.
|
||||
pub query: String,
|
||||
/// Max results (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GrepParams {
|
||||
/// Search text or regex query with optional constraint prefixes.
|
||||
/// Matches within single lines only — use ONE specific term, not multiple words.
|
||||
pub query: String,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_patterns<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct PatternsVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for PatternsVisitor {
|
||||
type Value = Vec<String>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string, an array of strings, or a stringified JSON array")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
// Try to parse as JSON array first
|
||||
if v.starts_with('[')
|
||||
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(v)
|
||||
{
|
||||
return Ok(parsed);
|
||||
}
|
||||
Ok(vec![v.to_string()])
|
||||
}
|
||||
|
||||
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
|
||||
if v.starts_with('[')
|
||||
&& let Ok(parsed) = serde_json::from_str::<Vec<String>>(&v)
|
||||
{
|
||||
return Ok(parsed);
|
||||
}
|
||||
Ok(vec![v])
|
||||
}
|
||||
|
||||
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
|
||||
let mut values = Vec::new();
|
||||
while let Some(value) = seq.next_element::<String>()? {
|
||||
values.push(value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(PatternsVisitor)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct MultiGrepParams {
|
||||
/// Patterns to match (OR logic). Include all naming conventions: snake_case, PascalCase, camelCase.
|
||||
#[serde(deserialize_with = "deserialize_patterns")]
|
||||
pub patterns: Vec<String>,
|
||||
/// File constraints (e.g. '*.{ts,tsx} !test/'). ALWAYS provide when possible.
|
||||
pub constraints: Option<String>,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
/// Cursor from previous result.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
/// Context lines before/after each match.
|
||||
pub context: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FffServer {
|
||||
picker: SharedPicker,
|
||||
#[allow(dead_code)]
|
||||
frecency: SharedFrecency,
|
||||
cursor_store: Arc<Mutex<CursorStore>>,
|
||||
update_notice_sent: Arc<AtomicBool>,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
|
||||
Self {
|
||||
picker,
|
||||
frecency,
|
||||
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
|
||||
update_notice_sent: Arc::new(AtomicBool::new(false)),
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the initial file scan to complete.
|
||||
#[allow(dead_code)]
|
||||
pub fn wait_for_scan(&self) {
|
||||
loop {
|
||||
let guard = self.picker.read().ok();
|
||||
let is_scanning = guard
|
||||
.as_ref()
|
||||
.and_then(|g| g.as_ref())
|
||||
.map(|p| p.is_scan_active())
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_scanning {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock the cursor store, returning an MCP error on poisoned mutex.
|
||||
fn lock_cursors(&self) -> Result<std::sync::MutexGuard<'_, CursorStore>, ErrorData> {
|
||||
self.cursor_store.lock().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire cursor store lock: {e}"), None)
|
||||
})
|
||||
}
|
||||
|
||||
/// If an update notice is available and hasn't been sent yet, append it
|
||||
/// to the tool result. Called once per server lifetime (first tool call).
|
||||
fn maybe_append_update_notice(&self, result: &mut CallToolResult) {
|
||||
if self.update_notice_sent.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let notice = crate::update_check::get_update_notice();
|
||||
if notice.is_empty() {
|
||||
// Reset so the next call can try again (check may still be in flight)
|
||||
self.update_notice_sent.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
result.content.push(Content::text(notice));
|
||||
}
|
||||
|
||||
/// Perform grep with auto-retry logic.
|
||||
///
|
||||
/// Acquires the picker read-lock once and holds it for the entire
|
||||
/// operation, so `GrepResult` references are used directly — no cloning.
|
||||
/// Always uses AI query parsing since this is an MCP server for AI agents.
|
||||
fn perform_grep(
|
||||
&self,
|
||||
query: &str,
|
||||
mode: GrepMode,
|
||||
max_results: usize,
|
||||
cursor_id: Option<&str>,
|
||||
output_mode: OutputMode,
|
||||
context: Option<usize>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let file_offset = cursor_id
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let (options, auto_expand) = make_grep_options(output_mode, mode, file_offset, context);
|
||||
let ctx_lines = options.before_context;
|
||||
|
||||
// Acquire picker lock once for the entire operation.
|
||||
let guard = self.picker.read().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
|
||||
})?;
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let parsed = parser.parse(query);
|
||||
let result = grep::grep_search(files, &parsed, &options);
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
|
||||
let parts: Vec<&str> = query.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let first_word = parts[0];
|
||||
let is_valid_constraint = first_word.starts_with('!')
|
||||
|| first_word.starts_with('*')
|
||||
|| first_word.ends_with('/');
|
||||
|
||||
if !is_valid_constraint {
|
||||
let rest_query = parts[1..].join(" ");
|
||||
let rest_parsed = parser.parse(&rest_query);
|
||||
|
||||
let rest_text = rest_parsed.grep_text();
|
||||
let retry_mode = if has_regex_metacharacters(&rest_text) {
|
||||
GrepMode::Regex
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
|
||||
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
|
||||
let retry_result = grep::grep_search(files, &rest_parsed, &retry_options);
|
||||
|
||||
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &retry_result.matches,
|
||||
files: &retry_result.files,
|
||||
total_matched: retry_result.matches.len(),
|
||||
next_file_offset: retry_result.next_file_offset,
|
||||
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 matches for '{}'. Auto-broadened to '{}':\n{}",
|
||||
query, rest_query, text
|
||||
))]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy fallback for typo tolerance
|
||||
let fuzzy_query = cleanup_fuzzy_query(query);
|
||||
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
|
||||
let fuzzy_parsed = parser.parse(&fuzzy_query);
|
||||
let fuzzy_result = grep::grep_search(files, &fuzzy_parsed, &fuzzy_options);
|
||||
|
||||
if !fuzzy_result.matches.is_empty() {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!(
|
||||
"0 exact matches. {} approximate:",
|
||||
fuzzy_result.matches.len()
|
||||
));
|
||||
let mut current_file = "";
|
||||
for m in fuzzy_result.matches.iter().take(3) {
|
||||
let file = fuzzy_result.files[m.file_index];
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
lines.push(current_file.to_string());
|
||||
}
|
||||
lines.push(format!(" {}: {}", m.line_number, m.line_content));
|
||||
}
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
lines.join("\n"),
|
||||
)]));
|
||||
}
|
||||
|
||||
// File path fallback: if query looks like a path, suggest the matching file
|
||||
if query.contains('/') {
|
||||
let file_parser = QueryParser::default();
|
||||
let file_query = file_parser.parse(query);
|
||||
let file_opts = FuzzySearchOptions {
|
||||
max_threads: 0,
|
||||
current_file: None,
|
||||
project_path: Some(picker.base_path()),
|
||||
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())
|
||||
{
|
||||
// Only suggest when the match is strong enough.
|
||||
let query_len = query.len() as i32;
|
||||
if score.base_score > query_len * 10 {
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 content matches. But there is a relevant file path: {}",
|
||||
top.relative_path
|
||||
))]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
if result.matches.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &result.matches,
|
||||
files: &result.files,
|
||||
total_matched: result.matches.len(),
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: result.regex_fallback_error.as_deref(),
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(text)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl FffServer {
|
||||
/// Fuzzy file search by name. Searches FILE NAMES, not file contents.
|
||||
/// Use it when you need to find a file, not a definition.
|
||||
/// Use grep instead for searching code content (definitions, usage patterns).
|
||||
/// Supports fuzzy matching, path prefixes ('shc/'), and glob constraints.
|
||||
/// IMPORTANT: Keep queries SHORT — prefer 1-2 terms max.
|
||||
#[tool(
|
||||
name = "find_files",
|
||||
description = "Fuzzy file search by name. Searches FILE NAMES, not file contents. Use it when you need to find a file, not a definition. Use grep instead for searching code content (definitions, usage patterns). Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('name **/src/*.{ts,tsx} !test/'). IMPORTANT: Keep queries SHORT — prefer 1-2 terms max. Multiple words are a waterfall (each narrows results), NOT OR. If unsure, start broad with 1 term and refine."
|
||||
)]
|
||||
fn find_files(
|
||||
&self,
|
||||
Parameters(params): Parameters<FindFilesParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let query = ¶ms.query;
|
||||
|
||||
let page_offset = params
|
||||
.cursor
|
||||
.as_deref()
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let guard = self.picker.read().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
|
||||
})?;
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
let base_path = picker.base_path();
|
||||
let make_opts = |offset: usize| FuzzySearchOptions {
|
||||
max_threads: 0,
|
||||
current_file: None,
|
||||
project_path: Some(base_path),
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset,
|
||||
limit: max_results,
|
||||
},
|
||||
};
|
||||
|
||||
let parser = QueryParser::default();
|
||||
let fff_query = parser.parse(query);
|
||||
let result = FilePicker::fuzzy_search(files, &fff_query, None, make_opts(page_offset));
|
||||
let total_files = result.total_files;
|
||||
|
||||
// Auto-retry with fewer terms if 3+ words return 0 results
|
||||
let words: Vec<&str> = query.split_whitespace().collect();
|
||||
let shorter = words.get(..2).map(|w| w.join(" "));
|
||||
|
||||
let (items, scores, total_matched) =
|
||||
if result.items.is_empty() && words.len() >= 3 && page_offset == 0 {
|
||||
if let Some(shorter) = &shorter {
|
||||
let shorter_query = parser.parse(shorter);
|
||||
let retry = FilePicker::fuzzy_search(
|
||||
files,
|
||||
&shorter_query,
|
||||
/*query_tracker=*/ None,
|
||||
make_opts(0),
|
||||
);
|
||||
|
||||
(retry.items, retry.scores, retry.total_matched)
|
||||
} else {
|
||||
(result.items, result.scores, result.total_matched)
|
||||
}
|
||||
} else {
|
||||
(result.items, result.scores, result.total_matched)
|
||||
};
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 results ({} indexed)",
|
||||
total_files
|
||||
))]));
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let top_item = items[0];
|
||||
let is_exact_match = scores[0].exact_match;
|
||||
|
||||
if page_offset == 0 {
|
||||
if is_exact_match {
|
||||
lines.push(format!("→ Read {} (exact match!)", top_item.relative_path));
|
||||
} else if scores.len() < 2 || scores[0].total > scores[1].total.saturating_mul(2) {
|
||||
lines.push(format!(
|
||||
"→ Read {} (best match — Read this file directly)",
|
||||
top_item.relative_path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let next_offset = page_offset + items.len();
|
||||
let has_more = next_offset < total_matched;
|
||||
|
||||
if has_more {
|
||||
lines.push(format!("{}/{} matches", items.len(), total_matched));
|
||||
}
|
||||
|
||||
for item in &items {
|
||||
lines.push(format!(
|
||||
"{}{}",
|
||||
item.relative_path,
|
||||
file_suffix(item.git_status, item.total_frecency_score)
|
||||
));
|
||||
}
|
||||
|
||||
if has_more {
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let cursor_id = cs.store(next_offset);
|
||||
lines.push(format!("cursor: {}", cursor_id));
|
||||
}
|
||||
|
||||
let mut result = CallToolResult::success(vec![Content::text(lines.join("\n"))]);
|
||||
self.maybe_append_update_notice(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Search file contents for text patterns. This is the DEFAULT search tool.
|
||||
/// Prefer plain text over regex. Filter files with constraints.
|
||||
#[tool(
|
||||
name = "grep",
|
||||
description = "Search file contents. Search for bare identifiers (e.g. 'InProgressQuote', 'ActorAuth'), NOT code syntax or regex. Filter files with constraints (e.g. '*.rs query', 'src/ query'). Use filename, directory (ending with /) or glob expressions to prefilter. See server instructions for constraint syntax and core rules."
|
||||
)]
|
||||
fn grep(
|
||||
&self,
|
||||
Parameters(params): Parameters<GrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let parsed = QueryParser::new(AiGrepConfig).parse(¶ms.query);
|
||||
let grep_text = parsed.grep_text();
|
||||
|
||||
let mode = if has_regex_metacharacters(&grep_text) {
|
||||
GrepMode::Regex
|
||||
} else {
|
||||
GrepMode::PlainText
|
||||
};
|
||||
|
||||
let mut result = self.perform_grep(
|
||||
¶ms.query,
|
||||
mode,
|
||||
max_results,
|
||||
params.cursor.as_deref(),
|
||||
output_mode,
|
||||
None,
|
||||
)?;
|
||||
self.maybe_append_update_notice(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Search file contents for lines matching ANY of multiple patterns (OR logic).
|
||||
/// Patterns are literal text — NEVER escape special characters.
|
||||
#[tool(
|
||||
name = "multi_grep",
|
||||
description = "Search file contents for lines matching ANY of multiple patterns (OR logic). IMPORTANT: This returns files where ANY query matches, NOT all patterns. Patterns are literal text — NEVER escape special characters (no \\( \\) \\. etc). Faster than regex alternation for literal text. See server instructions for constraint syntax."
|
||||
)]
|
||||
fn multi_grep(
|
||||
&self,
|
||||
Parameters(params): Parameters<MultiGrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let mut result = self.multi_grep_inner(params)?;
|
||||
self.maybe_append_update_notice(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let file_offset = params
|
||||
.cursor
|
||||
.as_deref()
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let (options, auto_expand) = make_grep_options(
|
||||
output_mode,
|
||||
GrepMode::PlainText,
|
||||
file_offset,
|
||||
params.context,
|
||||
);
|
||||
|
||||
let ctx_lines = options.before_context;
|
||||
let constraint_query = params.constraints.as_deref().unwrap_or("");
|
||||
let guard = self.picker.read().map_err(|e| {
|
||||
ErrorData::internal_error(format!("Failed to acquire picker lock: {e}"), None)
|
||||
})?;
|
||||
let picker = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
|
||||
let parsed_constraints = 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);
|
||||
let file_refs: Vec<&FileItem> = result.files.to_vec();
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Fallback: try individual patterns with plain grep
|
||||
let (fallback_options, _) =
|
||||
make_grep_options(output_mode, GrepMode::PlainText, 0, params.context);
|
||||
|
||||
let fallback_options = GrepSearchOptions {
|
||||
time_budget_ms: 3000,
|
||||
before_context: 0,
|
||||
..fallback_options
|
||||
};
|
||||
|
||||
for pat in ¶ms.patterns {
|
||||
let full_query: Cow<str> = if !constraint_query.is_empty() {
|
||||
Cow::Owned(format!("{} {}", constraint_query, pat))
|
||||
} else {
|
||||
Cow::Borrowed(pat)
|
||||
};
|
||||
|
||||
let parsed = parser.parse(&full_query);
|
||||
let fb_result = grep::grep_search(files, &parsed, &fallback_options);
|
||||
|
||||
if !fb_result.matches.is_empty() {
|
||||
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &fb_result.matches,
|
||||
files: &fb_file_refs,
|
||||
total_matched: fb_result.matches.len(),
|
||||
next_file_offset: fb_result.next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: false,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 multi-pattern matches. Plain grep fallback for \"{}\":\n{}",
|
||||
pat, text
|
||||
))]));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
if result.matches.is_empty() {
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
let mut cs = self.lock_cursors()?;
|
||||
let text = &GrepFormatter {
|
||||
matches: &result.matches,
|
||||
files: &file_refs,
|
||||
total_matched: result.matches.len(),
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: None,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context: ctx_lines > 0,
|
||||
auto_expand_defs: auto_expand,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(text)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for FffServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let notice = crate::update_check::get_update_notice();
|
||||
let instructions = if notice.is_empty() {
|
||||
crate::MCP_INSTRUCTIONS.to_string()
|
||||
} else {
|
||||
format!("{}{}", crate::MCP_INSTRUCTIONS, notice)
|
||||
};
|
||||
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new("fff", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions(instructions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Background update checker — compares the embedded build hash against
|
||||
//! the latest GitHub release tag to surface upgrade notices in MCP instructions.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const REPO: &str = "dmtrKovalenko/fff.nvim";
|
||||
const BUILD_HASH: &str = env!("FFF_GIT_HASH");
|
||||
|
||||
/// Holds the result of the update check (empty string = up to date or check failed).
|
||||
static UPDATE_NOTICE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Returns the update notice if the check has completed, empty string otherwise.
|
||||
pub fn get_update_notice() -> &'static str {
|
||||
UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Kick off the update check in a background thread so it never blocks the server.
|
||||
pub fn spawn_update_check() {
|
||||
std::thread::spawn(|| {
|
||||
let notice = check_latest_release();
|
||||
let _ = UPDATE_NOTICE.set(notice);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from GitHub and compare against the build hash.
|
||||
fn check_latest_release() -> String {
|
||||
match fetch_latest_tag() {
|
||||
Ok(tag) => compare_versions(BUILD_HASH, &tag),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare a build hash against a release tag.
|
||||
/// Returns an update notice string, or empty if up-to-date.
|
||||
fn compare_versions(build_hash: &str, release_tag: &str) -> String {
|
||||
let tag = release_tag.trim();
|
||||
if tag.is_empty() || build_hash == "unknown" {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let our_short = &build_hash[..build_hash.len().min(tag.len())];
|
||||
if our_short == tag {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!(
|
||||
"\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell out to curl to fetch the latest release tag name from GitHub API.
|
||||
fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = std::process::Command::new("curl")
|
||||
.args([
|
||||
"-fsSL",
|
||||
"--max-time",
|
||||
"5",
|
||||
"-H",
|
||||
"Accept: application/vnd.github.v3+json",
|
||||
&format!("https://api.github.com/repos/{REPO}/releases?per_page=1"),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("curl failed".into());
|
||||
}
|
||||
|
||||
let body = String::from_utf8(output.stdout)?;
|
||||
let releases: Vec<serde_json::Value> = serde_json::from_str(&body)?;
|
||||
let tag = releases
|
||||
.first()
|
||||
.and_then(|r| r.get("tag_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(tag)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -65,8 +65,6 @@ once_cell = "1.20.2"
|
||||
pathdiff = "0.2.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
|
||||
use 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;
|
||||
@@ -29,6 +29,7 @@ fn init_file_picker_internal(
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(shared_picker),
|
||||
Arc::clone(shared_frecency),
|
||||
)
|
||||
@@ -128,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);
|
||||
|
||||
@@ -164,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);
|
||||
@@ -225,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,
|
||||
@@ -268,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 {
|
||||
@@ -278,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,
|
||||
@@ -317,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 {
|
||||
@@ -324,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,
|
||||
@@ -350,8 +352,8 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search algorithm performance scaling with file count
|
||||
fn bench_search_scalability(c: &mut Criterion) {
|
||||
let all_files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (all_files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
|
||||
return;
|
||||
@@ -370,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 {
|
||||
@@ -382,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,
|
||||
@@ -419,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,
|
||||
@@ -451,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,
|
||||
@@ -476,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,
|
||||
@@ -500,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,
|
||||
@@ -525,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,
|
||||
@@ -549,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,
|
||||
@@ -586,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)
|
||||
@@ -593,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,
|
||||
@@ -618,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,
|
||||
@@ -643,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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -71,11 +71,14 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
page_limit: 100, // Get plenty of results
|
||||
mode: GrepMode::Fuzzy,
|
||||
time_budget_ms: 0, // No time limit — search all files
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let result = grep_search(files, &parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
eprintln!("══════════════════════════════════════════════════════════════");
|
||||
@@ -163,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);
|
||||
|
||||
|
||||
@@ -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::{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};
|
||||
@@ -119,6 +119,10 @@ struct GrepBench<'a> {
|
||||
|
||||
impl<'a> GrepBench<'a> {
|
||||
fn new(files: &'a [FileItem]) -> Self {
|
||||
Self::with_mode(files, GrepMode::PlainText)
|
||||
}
|
||||
|
||||
fn with_mode(files: &'a [FileItem], mode: GrepMode) -> Self {
|
||||
Self {
|
||||
files,
|
||||
options: GrepSearchOptions {
|
||||
@@ -127,8 +131,11 @@ impl<'a> GrepBench<'a> {
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
mode,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -137,7 +144,7 @@ impl<'a> GrepBench<'a> {
|
||||
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(self.files, query, parsed, &self.options);
|
||||
let result = grep_search(self.files, &parsed, &self.options);
|
||||
let elapsed = start.elapsed();
|
||||
(elapsed, result.matches.len(), result.total_files_searched)
|
||||
}
|
||||
@@ -215,12 +222,12 @@ 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);
|
||||
|
||||
// Direct file loading (no background thread)
|
||||
eprintln!("\n[1/5] Loading files...");
|
||||
eprintln!("\n[1/7] Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let load_time = load_start.elapsed();
|
||||
@@ -236,7 +243,7 @@ fn main() {
|
||||
|
||||
let bench = GrepBench::new(&files);
|
||||
|
||||
eprintln!("[2/5] Cold cache benchmarks (first search, mmap not yet loaded)");
|
||||
eprintln!("[2/7] Cold cache benchmarks (first search, mmap not yet loaded)");
|
||||
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
|
||||
print_header();
|
||||
|
||||
@@ -259,7 +266,7 @@ fn main() {
|
||||
print_row(name, &stats, matches, files_searched, 1);
|
||||
}
|
||||
|
||||
eprintln!("\n[3/5] Warm cache benchmarks (mmap cache populated)");
|
||||
eprintln!("\n[3/7] Warm cache benchmarks (plain text, mmap cache populated)");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
@@ -293,7 +300,84 @@ fn main() {
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
eprintln!("\n[4/5] Incremental typing simulation");
|
||||
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
|
||||
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
let fuzzy_bench = GrepBench::with_mode(&files, GrepMode::Fuzzy);
|
||||
|
||||
let fuzzy_queries: Vec<(&str, &str, usize)> = vec![
|
||||
("fuzzy_exact", "mutex_lock", 15),
|
||||
("fuzzy_typo", "mutx_lock", 15),
|
||||
("fuzzy_camel", "InodeOps", 15),
|
||||
("fuzzy_abbrev", "sched_rt", 15),
|
||||
("fuzzy_short", "kfr", 15),
|
||||
("fuzzy_common", "return", 10),
|
||||
("fuzzy_define", "MODULE_LICENSE", 15),
|
||||
("fuzzy_struct", "file_operations", 15),
|
||||
("fuzzy_long", "static_int_init", 15),
|
||||
("fuzzy_path", "printk *.c", 15),
|
||||
];
|
||||
|
||||
// Warmup
|
||||
for (_, query, _) in &fuzzy_queries {
|
||||
for _ in 0..3 {
|
||||
fuzzy_bench.run_once(query);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, query, iters) in &fuzzy_queries {
|
||||
let (stats, matches, files_searched) = fuzzy_bench.bench_query(query, *iters);
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy incremental typing ────────────────────────────────────────
|
||||
eprintln!("\n[5/7] Fuzzy incremental typing simulation");
|
||||
eprintln!(" Simulates user typing character by character (fuzzy mode).\n");
|
||||
|
||||
let fuzzy_typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
vec![
|
||||
"m",
|
||||
"mu",
|
||||
"mut",
|
||||
"mute",
|
||||
"mutex",
|
||||
"mutex_",
|
||||
"mutex_l",
|
||||
"mutex_lo",
|
||||
"mutex_loc",
|
||||
"mutex_lock",
|
||||
],
|
||||
),
|
||||
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
|
||||
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
|
||||
];
|
||||
|
||||
for (name, sequence) in &fuzzy_typing_sequences {
|
||||
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
"Query", "Latency", "Match", "Files"
|
||||
);
|
||||
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
|
||||
|
||||
for prefix in sequence {
|
||||
let (elapsed, matches, files_searched) = fuzzy_bench.run_once(prefix);
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
format!("\"{}\"", prefix),
|
||||
fmt_dur(elapsed),
|
||||
matches,
|
||||
files_searched,
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[6/7] Incremental typing simulation (plain text)");
|
||||
eprintln!(" Simulates user typing character by character.\n");
|
||||
|
||||
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
@@ -338,7 +422,7 @@ fn main() {
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[5/5] Pagination benchmark");
|
||||
eprintln!("[7/7] Pagination benchmark");
|
||||
eprintln!(" Testing page_offset performance for common query.\n");
|
||||
|
||||
let pagination_query = "return";
|
||||
@@ -363,9 +447,12 @@ fn main() {
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(&files, pagination_query, parsed, &opts);
|
||||
let result = grep_search(&files, &parsed, &opts);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -20,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;
|
||||
@@ -204,9 +205,32 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
page_limit: usize::MAX,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let result = grep_search(files, &parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 5000,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
@@ -222,9 +246,12 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let result = grep_search(files, &parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
@@ -292,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")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use fff_core::file_picker::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 {
|
||||
@@ -188,6 +187,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use fff_core::file_picker::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));
|
||||
@@ -81,6 +81,7 @@ fn main() {
|
||||
FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
@@ -125,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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use fff_core::file_picker::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};
|
||||
@@ -87,6 +87,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
@@ -199,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 {
|
||||
|
||||
@@ -2,9 +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::{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};
|
||||
@@ -48,6 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::default(),
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
@@ -152,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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+151
-52
@@ -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, 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;
|
||||
@@ -43,6 +45,10 @@ pub fn init_db(
|
||||
*frecency =
|
||||
Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock).into_lua_result()?);
|
||||
tracing::info!("Frecency database initialized at {}", frecency_db_path);
|
||||
drop(frecency);
|
||||
|
||||
// Spawn background GC to purge stale entries without blocking startup
|
||||
let _ = FrecencyTracker::spawn_gc(Arc::clone(&FRECENCY), frecency_db_path, use_unsafe_no_lock);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
@@ -52,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)
|
||||
}
|
||||
|
||||
@@ -91,6 +97,7 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)
|
||||
@@ -100,20 +107,28 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
// Stop existing picker
|
||||
// Cancel and stop the old picker under a single write lock to avoid
|
||||
// a window where FILE_PICKER is None (which causes FilePickerMissing
|
||||
// errors if the UI is searching concurrently).
|
||||
{
|
||||
let mut guard = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
if let Some(mut picker) = guard.take() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
picker.cancel();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Don't take() here — leave the old picker in place so searches
|
||||
// still work until new_with_shared_state replaces it atomically.
|
||||
}
|
||||
|
||||
// Create new picker backed by the same shared state
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string_lossy().to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)?;
|
||||
@@ -130,10 +145,16 @@ 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))
|
||||
})?;
|
||||
|
||||
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
|
||||
&& picker.base_path() == canonical_path
|
||||
{
|
||||
return Ok(()); // same dir
|
||||
}
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
@@ -197,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,
|
||||
@@ -225,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 {
|
||||
@@ -247,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)
|
||||
}
|
||||
|
||||
@@ -281,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),
|
||||
@@ -297,13 +335,66 @@ pub fn live_grep(
|
||||
page_limit: page_size.unwrap_or(50),
|
||||
mode,
|
||||
time_budget_ms: time_budget_ms.unwrap_or(0),
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed, &options);
|
||||
let result = fff::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);
|
||||
|
||||
@@ -459,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");
|
||||
@@ -564,21 +655,28 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
// Extract the scan signal Arc WITHOUT holding the read lock, so the
|
||||
// scan thread can acquire the write lock to store its results.
|
||||
// Holding a read lock while polling would deadlock: the scan thread
|
||||
// needs a write lock to finish, but can't acquire it while we hold the read lock.
|
||||
let scan_signal = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
picker.scan_signal()
|
||||
}; // read lock released here
|
||||
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() {
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
@@ -798,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)
|
||||
}
|
||||
|
||||
+2
-151
@@ -1,152 +1,3 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
//! Logging setup for fff-nvim — delegates to the shared fff-core::log utilities.
|
||||
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install panic hook that writes to both stderr and a fallback file
|
||||
/// This is called separately from init_tracing to ensure panics are always logged
|
||||
pub fn install_panic_hook() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF.nvim"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF.nvim PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("======================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_nvim_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
}
|
||||
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize tracing with single log file
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true) // creates a new file on every setup
|
||||
.open(log_path)?;
|
||||
|
||||
let level = match log_level
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
Some("debug") => tracing::Level::DEBUG,
|
||||
Some("info") => tracing::Level::INFO,
|
||||
Some("warn") => tracing::Level::WARN,
|
||||
Some("error") => tracing::Level::ERROR,
|
||||
_ => tracing::Level::INFO,
|
||||
};
|
||||
|
||||
TRACING_INITIALIZED.get_or_init(|| {
|
||||
let (non_blocking_appender, guard) = non_blocking(file_appender);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF.nvim tracing initialized with log file: {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
}
|
||||
pub use fff::log::{init_tracing, install_panic_hook};
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ impl PathCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size), level = tracing::Level::TRACE)]
|
||||
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
|
||||
self.map.get(path).and_then(|entry| {
|
||||
// Only return cached value if max_size matches
|
||||
@@ -85,7 +85,7 @@ pub fn shorten_path_with_cache(
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
|
||||
if let Some(cached) = cache.get(path, max_size) {
|
||||
tracing::debug!("Cache hit for path '{}'", path.display());
|
||||
tracing::trace!("Cache hit for path '{}'", path.display());
|
||||
return Ok(cached.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
use crate::constraints::Constraint;
|
||||
use crate::glob_detect::has_wildcards;
|
||||
|
||||
/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
|
||||
///
|
||||
/// A token is a filename/path if ALL of:
|
||||
/// - Does NOT end with `/` (that's a directory/PathSegment)
|
||||
/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
|
||||
/// - Last component (after final `/`) contains `.` with a valid-looking extension
|
||||
/// (1–10 alphanumeric chars starting with a letter, e.g. `rs`, `json`, `tsx`)
|
||||
///
|
||||
/// This covers both bare filenames (`score.rs`) and path-prefixed ones (`src/main.rs`).
|
||||
#[inline]
|
||||
fn is_filename_constraint_token(token: &str) -> bool {
|
||||
let bytes = token.as_bytes();
|
||||
|
||||
// Must NOT end with / (that's a PathSegment)
|
||||
if bytes.last() == Some(&b'/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must NOT contain wildcards (those are globs)
|
||||
if has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the filename component (after last /)
|
||||
let filename = token.rsplit('/').next().unwrap_or(token);
|
||||
|
||||
// Extension must exist and look like a real file extension:
|
||||
// starts with an ASCII letter (rejects version numbers like "v2.0"),
|
||||
// followed by alphanumeric chars, max 10 chars total.
|
||||
match filename.rfind('.') {
|
||||
Some(dot_pos) => {
|
||||
let ext = &filename[dot_pos + 1..];
|
||||
!ext.is_empty()
|
||||
&& ext.len() <= 10
|
||||
&& ext.as_bytes()[0].is_ascii_alphabetic()
|
||||
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parser configuration trait - allows different picker types to customize parsing
|
||||
pub trait ParserConfig {
|
||||
fn enable_glob(&self) -> bool {
|
||||
@@ -32,6 +73,13 @@ pub trait ParserConfig {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse location suffixes (e.g., file:12, file:12:4)
|
||||
/// Disabled for grep modes where colon-number patterns like localhost:8080
|
||||
/// are search text, not file locations.
|
||||
fn enable_location(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Determine whether a token should be treated as a glob constraint.
|
||||
///
|
||||
/// The default implementation delegates to `zlob::has_wildcards` with
|
||||
@@ -51,10 +99,19 @@ 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 {
|
||||
// All defaults enabled
|
||||
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.
|
||||
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
|
||||
if is_filename_constraint_token(token) {
|
||||
Some(Constraint::FilePath(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for full-text search (grep) - file constraints enabled for
|
||||
@@ -76,6 +133,10 @@ impl ParserConfig for GrepConfig {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_location(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Only recognise globs that are clearly directory/path oriented.
|
||||
///
|
||||
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
|
||||
@@ -98,12 +159,76 @@ 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
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
|
||||
/// automatic file-path constraint detection.
|
||||
///
|
||||
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
|
||||
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
|
||||
/// so the search is scoped to matching files. The caller validates the
|
||||
/// constraint against the index and drops it if no files match (fallback).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AiGrepConfig;
|
||||
|
||||
impl ParserConfig for AiGrepConfig {
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn enable_git_status(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_location(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// First check GrepConfig's strict rules (path globs, brace expansion)
|
||||
if GrepConfig.is_glob_pattern(token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
|
||||
// Recognise tokens that start AND end with `*` with non-empty text
|
||||
// between them as glob constraints. Bare `*` or `**` are excluded.
|
||||
if !has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
let bytes = token.as_bytes();
|
||||
if bytes.len() >= 3
|
||||
&& bytes[0] == b'*'
|
||||
&& bytes[bytes.len() - 1] == b'*'
|
||||
&& bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
|
||||
if is_filename_constraint_token(token) {
|
||||
Some(Constraint::FilePath(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ pub enum Constraint<'a> {
|
||||
/// Path constraint: /src/ -> PathSegment("src")
|
||||
PathSegment(&'a str),
|
||||
|
||||
/// File path constraint (AI mode): "libswscale/input.c" → FilePath("libswscale/input.c")
|
||||
/// Matches files whose relative path ends with this suffix at a `/` boundary.
|
||||
FilePath(&'a str),
|
||||
|
||||
/// File type constraint: type:rust -> FileType("rust")
|
||||
FileType(&'a str),
|
||||
|
||||
|
||||
@@ -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::{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) => {
|
||||
|
||||
@@ -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();
|
||||
@@ -44,33 +46,75 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
if whitespace_count == 0 {
|
||||
// Try to parse as constraint first
|
||||
if let Some(constraint) = parse_token(query, config) {
|
||||
constraints.push(constraint);
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Empty,
|
||||
location: None,
|
||||
});
|
||||
// Don't treat filename tokens (FilePath) as constraints in single-token
|
||||
// queries — the user is fuzzy-searching, not filtering. FilePath constraints
|
||||
// are only useful as filters in multi-token queries like "score.rs search".
|
||||
//
|
||||
// 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 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();
|
||||
let tokens = query.split_whitespace();
|
||||
|
||||
let mut has_file_path = false;
|
||||
for token in tokens {
|
||||
match parse_token(token, config) {
|
||||
Some(Constraint::FilePath(_)) => {
|
||||
if has_file_path {
|
||||
// Only one FilePath constraint allowed; treat extra path
|
||||
// tokens as literal text (e.g. an import path the user is
|
||||
// searching for).
|
||||
text_parts.push(token);
|
||||
} else {
|
||||
constraints.push(Constraint::FilePath(token));
|
||||
has_file_path = true;
|
||||
}
|
||||
}
|
||||
Some(constraint) => {
|
||||
constraints.push(constraint);
|
||||
}
|
||||
@@ -82,7 +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() {
|
||||
@@ -114,11 +158,12 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
}
|
||||
};
|
||||
|
||||
Some(FFFQuery {
|
||||
FFFQuery {
|
||||
raw_query,
|
||||
constraints,
|
||||
fuzzy_query,
|
||||
location,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,20 +190,27 @@ impl<'a> FFFQuery<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the leading `\` from a backslash-escaped token, returning the rest.
|
||||
/// For all other tokens returns the input unchanged.
|
||||
/// Strip the leading `\` from a backslash-escaped constraint token only.
|
||||
///
|
||||
/// We strip the backslash when the next character is a constraint trigger
|
||||
/// (`*`, `/`, `!`) — the user typed `\*.rs` to mean literal `*.rs`, not an
|
||||
/// extension constraint. For regex escape sequences like `\w`, `\b`, `\d`,
|
||||
/// `\s`, `\n` etc., the backslash is preserved so regex mode works correctly.
|
||||
#[inline]
|
||||
fn strip_leading_backslash(token: &str) -> &str {
|
||||
if token.starts_with('\\') && token.len() > 1 {
|
||||
&token[1..]
|
||||
} else {
|
||||
token
|
||||
if token.len() > 1 && token.starts_with('\\') {
|
||||
let next = token.as_bytes()[1];
|
||||
// Only strip if the backslash is escaping a constraint trigger character
|
||||
if next == b'*' || next == b'/' || next == b'!' {
|
||||
return &token[1..];
|
||||
}
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
impl Default for QueryParser<crate::FilePickerConfig> {
|
||||
impl Default for QueryParser<crate::FileSearchConfig> {
|
||||
fn default() -> Self {
|
||||
Self::new(crate::FilePickerConfig)
|
||||
Self::new(crate::FileSearchConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,11 +393,12 @@ fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
|
||||
}
|
||||
|
||||
/// Parse path segment with trailing slash: www/ -> PathSegment("www")
|
||||
/// Also supports multi-segment paths: libswscale/aarch64/ -> PathSegment("libswscale/aarch64")
|
||||
#[inline]
|
||||
fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
|
||||
if token.len() > 1 && token.ends_with('/') {
|
||||
let segment = token.trim_end_matches('/');
|
||||
if !segment.is_empty() && !segment.contains('/') {
|
||||
if !segment.is_empty() {
|
||||
Some(Constraint::PathSegment(segment))
|
||||
} else {
|
||||
None
|
||||
@@ -384,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() {
|
||||
@@ -399,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);
|
||||
@@ -428,18 +481,23 @@ mod tests {
|
||||
parse_path_segment_trailing("src/"),
|
||||
Some(Constraint::PathSegment("src"))
|
||||
);
|
||||
// Should not match paths with multiple segments
|
||||
assert_eq!(parse_path_segment_trailing("src/lib/"), None);
|
||||
// Multi-segment paths should work
|
||||
assert_eq!(
|
||||
parse_path_segment_trailing("src/lib/"),
|
||||
Some(Constraint::PathSegment("src/lib"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_path_segment_trailing("libswscale/aarch64/"),
|
||||
Some(Constraint::PathSegment("libswscale/aarch64"))
|
||||
);
|
||||
// Should not match without trailing slash
|
||||
assert_eq!(parse_path_segment_trailing("www"), None);
|
||||
}
|
||||
|
||||
#[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],
|
||||
@@ -474,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) => {
|
||||
@@ -490,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) => {
|
||||
@@ -505,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) => {
|
||||
@@ -520,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) => {
|
||||
@@ -538,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
|
||||
@@ -557,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) => {
|
||||
@@ -573,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);
|
||||
}
|
||||
@@ -692,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!(
|
||||
@@ -706,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],
|
||||
@@ -716,12 +731,51 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_preserves_backslash_escapes() {
|
||||
// Regex patterns like \w+ and \bfoo\b must survive grep_text()
|
||||
// The parser sees \w+ as a text token (not a constraint escape),
|
||||
// but strip_leading_backslash was stripping the \ anyway.
|
||||
let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"pub struct \\w+",
|
||||
"Backslash-w in regex must be preserved"
|
||||
);
|
||||
|
||||
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 FFFQuery with Text fuzzy query
|
||||
let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
|
||||
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");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"*.rs foo",
|
||||
"Escaped constraint \\*.rs should still have backslash stripped"
|
||||
);
|
||||
|
||||
let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"/src/ foo",
|
||||
"Escaped constraint \\/src/ should still have backslash stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_bare_star_is_text() {
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// "a*b" contains * but no / or {} — should be text in grep mode
|
||||
let result = parser.parse("a*b something");
|
||||
let result = result.expect("Should parse");
|
||||
assert_eq!(
|
||||
result.constraints.len(),
|
||||
0,
|
||||
@@ -732,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) => {
|
||||
@@ -751,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) => {
|
||||
@@ -770,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) => {
|
||||
@@ -785,4 +833,345 @@ mod tests {
|
||||
other => panic!("Expected Not constraint, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI grep config tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_detects_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("libswscale/input.c rgba32ToY");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("libswscale/input.c")
|
||||
),
|
||||
"Expected FilePath, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.grep_text(), "rgba32ToY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_detects_nested_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/main.rs fn main");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("src/main.rs")
|
||||
));
|
||||
assert_eq!(result.grep_text(), "fn main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_no_false_positive_trailing_slash() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/ pattern");
|
||||
// Should be PathSegment, NOT FilePath
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::PathSegment("src")),
|
||||
"Expected PathSegment, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_filename_is_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("main.rs pattern");
|
||||
// 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(), "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");
|
||||
// No extension in last component → not a file path, just text
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
assert_eq!(result.grep_text(), "src/utils pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_wildcard_not_filepath() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("src/**/*.rs pattern");
|
||||
// Contains wildcards → should be a Glob, not FilePath
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
|
||||
"Expected Glob, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_star_text_star_is_glob() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("*quote* TODO");
|
||||
// `*quote*` should be recognised as a glob constraint in AI mode
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::Glob("*quote*")),
|
||||
"Expected Glob(*quote*), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_star_not_glob() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("* pattern");
|
||||
// Bare `*` should NOT be treated as a glob (too broad)
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"Expected no constraints, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_no_location_parsing_single_token() {
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// localhost:8080 should NOT be parsed as location -- it's a search pattern
|
||||
let result = parser.parse("localhost:8080");
|
||||
assert!(result.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");
|
||||
// Regular grep mode should NOT treat `*quote*` as a glob
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"Expected no constraints in GrepConfig, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
|
||||
// ── File picker filename constraint tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_bare_filename_constraint() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
let result = parser.parse("score.rs file_picker");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath("score.rs")),
|
||||
"Expected FilePath(\"score.rs\"), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_path_prefixed_filename_constraint() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
let result = parser.parse("libswscale/slice.c lum_convert");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("libswscale/slice.c")
|
||||
),
|
||||
"Expected FilePath(\"libswscale/slice.c\"), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_single_token_filename_stays_fuzzy() {
|
||||
let parser = QueryParser::new(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.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(FileSearchConfig);
|
||||
let result = parser.parse("main.rs src components");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("main.rs")
|
||||
));
|
||||
assert_eq!(
|
||||
result.fuzzy_query,
|
||||
FuzzyQuery::Parts(smallvec::smallvec!["src", "components"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_version_number_not_filename() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
let result = parser.parse("v2.0 release");
|
||||
// v2.0 extension starts with digit → not a filename constraint
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"v2.0 should not be a FilePath constraint, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_only_one_filepath_constraint() {
|
||||
let parser = QueryParser::new(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!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("main.rs")
|
||||
));
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_filename_with_extension_constraint() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
let result = parser.parse("main.rs *.lua");
|
||||
// main.rs → FilePath, *.lua → Extension
|
||||
assert_eq!(result.constraints.len(), 2);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("main.rs")
|
||||
));
|
||||
assert!(matches!(
|
||||
result.constraints[1],
|
||||
Constraint::Extension("lua")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_dotfile_is_filename() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
let result = parser.parse(".gitignore src");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
|
||||
"Expected FilePath(\".gitignore\"), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_no_extension_not_filename() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
let result = parser.parse("Makefile src");
|
||||
// No dot → not a filename constraint
|
||||
assert!(
|
||||
result.constraints.is_empty(),
|
||||
"Makefile should not be a FilePath constraint, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+82
-75
@@ -1,52 +1,68 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 28
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 18
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
- Features |fff.nvim-features|
|
||||
- Installation |fff.nvim-installation|
|
||||
FFF.nvimFinally a smart fuzzy file picker for neovim.
|
||||
- MCP |fff.nvim-mcp|
|
||||
- Neovim guide |fff.nvim-neovim-guide|
|
||||
1. Links |fff.nvim-links|
|
||||
FFFAI agents (MCP) | Neovim usersA fast file search for your AI and neovim, with memory built-in
|
||||
|
||||
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
|
||||
opinionated fuzzy file picker for neovim. Just for files, but we’ll try to
|
||||
solve file picking completely.
|
||||
------------------------------------------------------------------------------
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
|
||||
opinionated fuzzy file picker for your AI agent and Neovim. Just for file
|
||||
search, but we do the file search really fff well.
|
||||
|
||||
It comes with a dedicated rust backend runtime that keep tracks of the file
|
||||
index, your file access and modifications, git status, and provides a
|
||||
comprehensive typo-resistant fuzzy search experience.
|
||||
FFF is a tool for grepping, fuzzy file matching, globbing, and multigrepping
|
||||
with a strong focus on performance and useful search results. For humans -
|
||||
provides an unbelievable typo-resistant experience, for AI agents - implements
|
||||
the fastest file search with additional free memory suggesting the best search
|
||||
results based on various factors like frecency, git status, file size,
|
||||
definition matches, and more.
|
||||
|
||||
|
||||
FEATURES *fff.nvim-features*
|
||||
MCP *fff.nvim-mcp*
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- Typo resistant fuzzy search <https://github.com/saghen/frizbee>
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
FFF is an amazing way to reduce the time and tokens by giving your AI agent a
|
||||
bit of memory built-in to their file search tools. It makes your AI harness to
|
||||
find the code faster and spend less tokens by doing less roundtrips and reading
|
||||
less useless files.
|
||||
|
||||
You can install FFF as a dependency for your AI agent using a simple bash
|
||||
script:
|
||||
|
||||
>bash
|
||||
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
|
||||
<
|
||||
|
||||
|
||||
INSTALLATION *fff.nvim-installation*
|
||||
The installation script is here ./install-mcp.sh <./install-mcp.sh> if you want
|
||||
to review it before running.
|
||||
It will print out the instructions on how to connect it to your `Claude Code`,
|
||||
`Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to
|
||||
"use fff". Here is an example addition to `CLAUDE.md` that works perfectly:
|
||||
|
||||
>sh
|
||||
# CLAUDE.md
|
||||
For any file search or grep in the current git indexed directory use fff tools
|
||||
<
|
||||
|
||||
|
||||
[!NOTE] Although we’ll try to make sure to keep 100% backward compatibility,
|
||||
by using you should understand that silly bugs and breaking changes may happen.
|
||||
And also we hope for your contributions and feedback to make this plugin ideal
|
||||
for everyone.
|
||||
NEOVIM GUIDE *fff.nvim-neovim-guide*
|
||||
|
||||
PREREQUISITES ~
|
||||
Here is some demo on the linux repository (100k files, 8GB) but you better fill
|
||||
it yourself and see the magic
|
||||
|
||||
FFF.nvim requires:
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- Rustup <https://rustup.rs/> (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
|
||||
|
||||
INSTALLATION ~
|
||||
|
||||
FFF.nvim requires neovim 0.10.0 or higher
|
||||
|
||||
|
||||
LAZY.NVIM
|
||||
|
||||
@@ -261,9 +277,8 @@ all available options:
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
enabled = false, -- Show file info panel in preview
|
||||
show_scores = false, -- Show scores inline in the UI
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -292,20 +307,21 @@ KEY FEATURES ~
|
||||
AVAILABLE METHODS
|
||||
|
||||
>lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repository
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file list
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
<
|
||||
|
||||
just jump to the definition and see what other APIs are exposed we have a
|
||||
plenty
|
||||
|
||||
|
||||
COMMANDS
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
@@ -314,13 +330,6 @@ FFF.nvim provides several commands for interacting with the file picker:
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
|
||||
MULTILINE PASTE SUPPORT
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining
|
||||
all lines into a single search query. This is particularly useful when copying
|
||||
file paths from terminal output.
|
||||
|
||||
|
||||
DEBUG MODE
|
||||
|
||||
Toggle scoring information display:
|
||||
@@ -388,6 +397,34 @@ When only one mode is configured, the mode indicator is hidden completely and
|
||||
the cycle keybind does nothing.
|
||||
|
||||
|
||||
CONSTRAINTS
|
||||
|
||||
There are a number of constraints you can use to refine your search in both
|
||||
grep and file search mode:
|
||||
|
||||
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
|
||||
- `test/` - any deeply nested children of any test/ dir
|
||||
- `!something` - exclude results matching something
|
||||
- `!test/`, `!git:modified` - combining with any other constraint works as negation
|
||||
- `./**/*.{rs,lua}` - any valid glob expression via the fastest globbing library <https://github.com/dmtrKovalenko/zlob>
|
||||
|
||||
For grep only:
|
||||
|
||||
- `*.md`, `*.{c,h}` - extension filtering
|
||||
- `src/main.rs` - grep in a single file
|
||||
|
||||
In addition to that, all constraints can be combined together like:
|
||||
|
||||
>
|
||||
git:modified src/**/*.rs !src/**/mod.rs user controller
|
||||
<
|
||||
|
||||
This will find all the files that qualify the constraints and:
|
||||
|
||||
- match **both** user and controller (for file mode)
|
||||
- match "user controller" (for grep mode)
|
||||
|
||||
|
||||
CROSS-MODE SUGGESTIONS
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search
|
||||
@@ -497,47 +534,17 @@ VIEWING LOGS
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
>vim
|
||||
>
|
||||
:FFFOpenLog
|
||||
<
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
|
||||
location).
|
||||
|
||||
==============================================================================
|
||||
1. Links *fff.nvim-links*
|
||||
|
||||
COMMON ISSUES
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it’s properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
|
||||
DEBUG MODE
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
1. *Chart showing the superiority of fff.nvim over builtin claude code tools*: ./chart.png
|
||||
|
||||
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -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": {
|
||||
|
||||
Executable
+272
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
# FFF MCP Server installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
|
||||
|
||||
REPO="dmtrKovalenko/fff.nvim"
|
||||
BINARY_NAME="fff-mcp"
|
||||
INSTALL_DIR="${FFF_MCP_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
info() { printf '\033[1;34m%s\033[0m\n' "$*"; }
|
||||
success() { printf '\033[1;38;5;208m%s\033[0m\n' "$*"; }
|
||||
warn() { printf '\033[1;33m%s\033[0m\n' "$*"; }
|
||||
error() { printf '\033[1;31mError: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Print JSON with syntax highlighting via jq if available, plain otherwise
|
||||
print_json() {
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "$1" | jq .
|
||||
else
|
||||
echo "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
local os arch target
|
||||
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
case "$os" in
|
||||
Linux)
|
||||
# Prefer musl (static) for maximum compatibility
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-unknown-linux-musl" ;;
|
||||
aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
Darwin)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-apple-darwin" ;;
|
||||
aarch64|arm64) target="aarch64-apple-darwin" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-pc-windows-msvc" ;;
|
||||
aarch64|arm64) target="aarch64-pc-windows-msvc" ;;
|
||||
*) error "Unsupported architecture: $arch" ;;
|
||||
esac
|
||||
;;
|
||||
*) error "Unsupported OS: $os" ;;
|
||||
esac
|
||||
|
||||
echo "$target"
|
||||
}
|
||||
|
||||
get_latest_release_tag() {
|
||||
local target="$1"
|
||||
local releases_json
|
||||
releases_json=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases") \
|
||||
|| error "Failed to fetch releases from https://github.com/${REPO}/releases"
|
||||
|
||||
# Find the first release that contains an fff-mcp binary for our platform
|
||||
local tag
|
||||
tag=$(echo "$releases_json" \
|
||||
| grep -oE '"(tag_name|name)": *"[^"]*"' \
|
||||
| awk -v target="fff-mcp-${target}" '
|
||||
/"tag_name":/ { gsub(/.*": *"|"/, ""); current_tag = $0; next }
|
||||
/"name":/ && index($0, target) { print current_tag; exit }
|
||||
')
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
error "No release found containing fff-mcp binaries for ${target}. The MCP build may not have been released yet."
|
||||
fi
|
||||
echo "$tag"
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local target="$1"
|
||||
local tag="$2"
|
||||
local ext=""
|
||||
|
||||
case "$target" in
|
||||
*windows*) ext=".exe" ;;
|
||||
esac
|
||||
|
||||
local filename="${BINARY_NAME}-${target}${ext}"
|
||||
local url="https://github.com/${REPO}/releases/download/${tag}/${filename}"
|
||||
local checksum_url="${url}.sha256"
|
||||
|
||||
info "Downloading ${filename} from release ${tag}..."
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
if ! curl -fsSL -o "${tmp_dir}/${filename}" "$url" 2>/dev/null; then
|
||||
echo "" >&2
|
||||
printf '\033[1;31mError: Failed to download binary for your platform.\033[0m\n' >&2
|
||||
echo "" >&2
|
||||
echo " URL: ${url}" >&2
|
||||
echo " Release: ${tag}" >&2
|
||||
echo " Platform: ${target}" >&2
|
||||
echo "" >&2
|
||||
echo "This likely means the MCP binary hasn't been built for this release yet." >&2
|
||||
echo "Check available releases at: https://github.com/${REPO}/releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify checksum if sha256sum is available
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
if curl -fsSL -o "${tmp_dir}/${filename}.sha256" "$checksum_url" 2>/dev/null; then
|
||||
info "Verifying checksum..."
|
||||
(cd "$tmp_dir" && sha256sum -c "${filename}.sha256") \
|
||||
|| error "Checksum verification failed!"
|
||||
else
|
||||
warn "Checksum file not available, skipping verification."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
|
||||
if [ "$IS_UPDATE" != true ]; then
|
||||
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_path() {
|
||||
case ":$PATH:" in
|
||||
*":${INSTALL_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
|
||||
warn "${INSTALL_DIR} is not in your PATH."
|
||||
echo ""
|
||||
echo "Add it to your shell profile:"
|
||||
echo ""
|
||||
|
||||
local shell_name
|
||||
shell_name="$(basename "${SHELL:-bash}")"
|
||||
case "$shell_name" in
|
||||
zsh)
|
||||
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc"
|
||||
echo " source ~/.zshrc"
|
||||
;;
|
||||
fish)
|
||||
echo " fish_add_path ${INSTALL_DIR}"
|
||||
;;
|
||||
*)
|
||||
echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc"
|
||||
echo " source ~/.bashrc"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_setup_instructions() {
|
||||
local binary_path="${INSTALL_DIR}/${BINARY_NAME}"
|
||||
local found_any=false
|
||||
|
||||
echo ""
|
||||
success "FFF MCP Server installed successfully!"
|
||||
echo ""
|
||||
info "Setup with your AI coding assistant:"
|
||||
echo ""
|
||||
|
||||
# Claude Code
|
||||
if command -v claude &>/dev/null; then
|
||||
found_any=true
|
||||
success "[Claude Code] detected"
|
||||
echo ""
|
||||
echo "Global (recommended):"
|
||||
echo "claude mcp add -s user fff -- ${binary_path}"
|
||||
echo ""
|
||||
echo "Or project-level .mcp.json (uses PATH):"
|
||||
echo ""
|
||||
print_json '{
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# OpenCode
|
||||
if command -v opencode &>/dev/null; then
|
||||
found_any=true
|
||||
success "[OpenCode] detected"
|
||||
echo ""
|
||||
echo "Add to ~/.config/opencode/opencode.json:"
|
||||
echo ""
|
||||
print_json '{
|
||||
"mcp": {
|
||||
"fff": {
|
||||
"type": "local",
|
||||
"command": ["fff-mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Codex
|
||||
if command -v codex &>/dev/null; then
|
||||
found_any=true
|
||||
success "[Codex] detected"
|
||||
echo ""
|
||||
echo "codex mcp add fff -- fff-mcp"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$found_any" = false ]; then
|
||||
echo "No AI coding assistants detected."
|
||||
echo ""
|
||||
echo "Binary path: ${binary_path}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Binary: ${binary_path}"
|
||||
echo "Docs: https://github.com/${REPO}"
|
||||
echo ""
|
||||
info "Tip: Add this to your CLAUDE.md or AGENTS.md to make AI use fff for all searches:"
|
||||
echo "\""
|
||||
echo "Use the fff MCP tools for all file search operations instead of default tools."
|
||||
echo "\""
|
||||
|
||||
|
||||
}
|
||||
|
||||
main() {
|
||||
local target
|
||||
target="$(detect_platform)"
|
||||
|
||||
local existing_binary="${INSTALL_DIR}/${BINARY_NAME}"
|
||||
IS_UPDATE=false
|
||||
|
||||
if [ -x "$existing_binary" ]; then
|
||||
IS_UPDATE=true
|
||||
info "Updating FFF MCP Server..."
|
||||
else
|
||||
info "Installing FFF MCP Server..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
info "Detected platform: ${target}"
|
||||
|
||||
local tag
|
||||
tag="$(get_latest_release_tag "$target")"
|
||||
|
||||
download_binary "$target" "$tag"
|
||||
|
||||
if [ "$IS_UPDATE" = true ]; then
|
||||
echo ""
|
||||
success "FFF MCP Server updated to ${tag}!"
|
||||
echo ""
|
||||
else
|
||||
check_path
|
||||
print_setup_instructions
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
+3
-4
@@ -313,9 +313,8 @@ local function init()
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
enabled = false, -- Show file info panel in preview
|
||||
show_scores = false, -- Show scores inline in the UI
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -356,7 +355,7 @@ end
|
||||
function M.toggle_debug()
|
||||
local old_debug_state = state.config.debug.show_scores
|
||||
state.config.debug.show_scores = not state.config.debug.show_scores
|
||||
state.config.debug.show_file_info = state.config.debug.show_scores
|
||||
state.config.debug.enabled = state.config.debug.show_scores
|
||||
local status = state.config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
return old_debug_state ~= state.config.debug.show_scores
|
||||
|
||||
@@ -65,12 +65,6 @@ local function setup_global_autocmds(config)
|
||||
end,
|
||||
desc = 'Automatically sync FFF directory changes',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('VimLeavePre', {
|
||||
group = group,
|
||||
callback = function() pcall(fuzzy.cleanup_file_picker) end,
|
||||
desc = 'Cleanup FFF background threads on Neovim exit',
|
||||
})
|
||||
end
|
||||
|
||||
--- @return boolean
|
||||
|
||||
@@ -114,7 +114,7 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
vim.uv.fanoushkas_unlink(tmp_path)
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
+113
-43
@@ -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)
|
||||
@@ -732,7 +795,12 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('wrap', false, { buf = bufnr })
|
||||
|
||||
-- Set wrap on the window (wrap is window-local, not buffer-local)
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
for _, win in ipairs(wins) do
|
||||
if vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_option_value('wrap', false, { win = win }) end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -797,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
|
||||
|
||||
@@ -47,7 +47,9 @@ end
|
||||
local function render_match_line(item, ctx)
|
||||
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
|
||||
local separator = ' '
|
||||
local raw_content = item.line_content or ''
|
||||
-- vim.json.decode may return Blobs for strings with NUL bytes; coerce to string.
|
||||
local raw_content = item.line_content
|
||||
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
|
||||
local content = raw_content
|
||||
|
||||
-- Indent + location + separator + content
|
||||
|
||||
+20
-12
@@ -69,8 +69,16 @@ local function generate_item_lines(ctx)
|
||||
local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0
|
||||
if has_suggestion_header then
|
||||
table.insert(suggestion_header_lines, '')
|
||||
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
|
||||
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
|
||||
if ctx.mode == 'grep' and ctx.suggestion_source == 'files' then
|
||||
-- Grep mode with no results — hint about mode cycling to fuzzy search
|
||||
local config = require('fff.conf').get()
|
||||
local keybind = config.keymaps.cycle_grep_modes
|
||||
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
|
||||
table.insert(suggestion_header_lines, ' No results, try ' .. keybind .. ' to fuzzy search')
|
||||
else
|
||||
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
|
||||
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
|
||||
end
|
||||
table.insert(suggestion_header_lines, '')
|
||||
end
|
||||
|
||||
@@ -189,17 +197,17 @@ local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
local item = ctx.items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
if item_lines then
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
if not line_content then goto continue end
|
||||
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
if line_content then
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -224,7 +232,7 @@ function M.render(ctx, list_buf, list_win, ns_id)
|
||||
local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg'
|
||||
for i = 0, #lines - 1 do
|
||||
local line = lines[i + 1]
|
||||
if line and line:match('^%s+No results found') then
|
||||
if line and (line:match('^%s+No results found') or line:match('^%s+No results,')) then
|
||||
pcall(
|
||||
vim.api.nvim_buf_set_extmark,
|
||||
list_buf,
|
||||
|
||||
+9
-25
@@ -42,6 +42,8 @@ function M.live_grep(opts)
|
||||
picker_ui.open(picker_opts)
|
||||
end
|
||||
|
||||
--- Changes the directory indexed by the file picker to the git root and opens the file picker
|
||||
--- @deprecated Use `find_files` instead
|
||||
function M.find_in_git_root()
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, git_root = pcall(fuzzy.get_git_root)
|
||||
@@ -163,11 +165,12 @@ function M.find_files_in_dir(directory)
|
||||
return
|
||||
end
|
||||
|
||||
M.change_indexing_directory(directory)
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
|
||||
picker_ui.open({
|
||||
title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t'),
|
||||
cwd = directory,
|
||||
})
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
@@ -177,28 +180,9 @@ end
|
||||
--- @param new_path string New directory path to use as base
|
||||
--- @return boolean `true` if successful, `false` otherwise
|
||||
function M.change_indexing_directory(new_path)
|
||||
if not new_path or new_path == '' then
|
||||
vim.notify('Directory path is required', vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local expanded_path = vim.fn.expand(new_path)
|
||||
|
||||
if vim.fn.isdirectory(expanded_path) ~= 1 then
|
||||
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local config = require('fff.conf').get()
|
||||
config.base_path = expanded_path
|
||||
return true
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then return picker_ui.change_indexing_directory(new_path) end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Opens the file under the cursor with an optional callback if the only file
|
||||
|
||||
+75
-1
@@ -90,7 +90,7 @@ local function get_preview_position()
|
||||
end
|
||||
|
||||
local function compute_layout(config)
|
||||
local debug_enabled_in_preview = M.enabled_preview() and config.debug and config.debug.show_file_info or false
|
||||
local debug_enabled_in_preview = M.enabled_preview() and config.debug and config.debug.enabled or false
|
||||
|
||||
local terminal_width = vim.o.columns
|
||||
local terminal_height = vim.o.lines
|
||||
@@ -638,6 +638,16 @@ function M.setup_windows()
|
||||
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.list_win })
|
||||
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.list_win })
|
||||
|
||||
if M.state.file_info_win and vim.api.nvim_win_is_valid(M.state.file_info_win) then
|
||||
vim.api.nvim_set_option_value('wrap', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('cursorline', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('number', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('signcolumn', 'no', { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.file_info_win })
|
||||
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.file_info_win })
|
||||
end
|
||||
|
||||
if M.enabled_preview() then
|
||||
vim.api.nvim_set_option_value('wrap', false, { win = M.state.preview_win })
|
||||
vim.api.nvim_set_option_value('cursorline', M.state.mode == 'grep', { win = M.state.preview_win })
|
||||
@@ -1690,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
|
||||
@@ -2283,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()
|
||||
|
||||
@@ -2591,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
|
||||
@@ -2606,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
|
||||
|
||||
|
||||
Generated
+709
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": ["packages/fff-bun", "packages/fff-node"],
|
||||
|
||||
"scripts": {
|
||||
"format": "biome format --write",
|
||||
"format:check": "biome format",
|
||||
"lint": "biome lint",
|
||||
"check": "biome check --write",
|
||||
"check:ci": "biome check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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"]
|
||||
}
|
||||
+2
-2
@@ -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"]
|
||||
}
|
||||
+2
-2
@@ -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"]
|
||||
}
|
||||
+2
-2
@@ -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"]
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Native binaries (downloaded at install)
|
||||
bin/*.dylib
|
||||
bin/*.so
|
||||
bin/*.dll
|
||||
+18
-18
@@ -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
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -8,6 +8,16 @@
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-x64": "0.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0",
|
||||
},
|
||||
|
||||
Regular → Executable
+16
-31
@@ -11,7 +11,7 @@
|
||||
|
||||
import { FileFinder } from "../src/index";
|
||||
import type { GrepMode } from "../src/types";
|
||||
import * as readline from "readline";
|
||||
import * as readline from "node:readline";
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const BOLD = "\x1b[1m";
|
||||
@@ -64,9 +64,7 @@ function highlightLine(content: string, ranges: [number, number][]): string {
|
||||
if (s > lastEnd) {
|
||||
parts.push(buf.subarray(lastEnd, s).toString("utf-8"));
|
||||
}
|
||||
parts.push(
|
||||
`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`
|
||||
);
|
||||
parts.push(`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`);
|
||||
lastEnd = e;
|
||||
}
|
||||
|
||||
@@ -131,7 +129,7 @@ async function main() {
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
process.stdout.write(
|
||||
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `
|
||||
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `,
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
@@ -139,19 +137,17 @@ async function main() {
|
||||
|
||||
const scanTime = Date.now() - startTime;
|
||||
const finalProgress = finder.getScanProgress();
|
||||
const totalFiles = finalProgress.ok
|
||||
? finalProgress.value.scannedFilesCount
|
||||
: 0;
|
||||
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
|
||||
|
||||
console.log(
|
||||
`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`
|
||||
`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`
|
||||
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`,
|
||||
);
|
||||
console.log(
|
||||
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`
|
||||
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`,
|
||||
);
|
||||
|
||||
let currentMode = mode;
|
||||
@@ -176,16 +172,12 @@ async function main() {
|
||||
// Handle mode switching
|
||||
if (query.startsWith(":mode ")) {
|
||||
const newMode = query.slice(6).trim();
|
||||
if (
|
||||
newMode === "plain" ||
|
||||
newMode === "regex" ||
|
||||
newMode === "fuzzy"
|
||||
) {
|
||||
if (newMode === "plain" || newMode === "regex" || newMode === "fuzzy") {
|
||||
currentMode = newMode;
|
||||
console.log(`${DIM}Switched to ${currentMode} mode${RESET}\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`
|
||||
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`,
|
||||
);
|
||||
}
|
||||
prompt();
|
||||
@@ -198,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,
|
||||
@@ -215,7 +207,7 @@ async function main() {
|
||||
items,
|
||||
totalMatched,
|
||||
totalFilesSearched,
|
||||
totalFiles: indexedFiles,
|
||||
totalFiles: _,
|
||||
filteredFileCount,
|
||||
nextCursor,
|
||||
regexFallbackError,
|
||||
@@ -225,12 +217,12 @@ async function main() {
|
||||
|
||||
if (regexFallbackError) {
|
||||
console.log(
|
||||
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`
|
||||
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`
|
||||
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
@@ -246,16 +238,11 @@ async function main() {
|
||||
if (match.relativePath !== lastFile) {
|
||||
lastFile = match.relativePath;
|
||||
const git = formatGitStatus(match.gitStatus);
|
||||
console.log(
|
||||
`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`
|
||||
);
|
||||
console.log(`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`);
|
||||
}
|
||||
|
||||
const lineNum = String(match.lineNumber).padStart(4);
|
||||
const highlighted = highlightLine(
|
||||
match.lineContent,
|
||||
match.matchRanges
|
||||
);
|
||||
const highlighted = highlightLine(match.lineContent, match.matchRanges);
|
||||
|
||||
let suffix = "";
|
||||
if (match.fuzzyScore !== undefined) {
|
||||
@@ -266,9 +253,7 @@ async function main() {
|
||||
}
|
||||
|
||||
if (nextCursor) {
|
||||
console.log(
|
||||
`\n${DIM}... more results available${RESET}`
|
||||
);
|
||||
console.log(`\n${DIM}... more results available${RESET}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user