Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fc29fbafc | |||
| 1c2c0633cd | |||
| 5158ba64b8 | |||
| 51e0ef7a64 | |||
| cd0efe50d3 | |||
| 541c3f5722 | |||
| 59d626dacd | |||
| 29e6480ea0 | |||
| db4cd2825c | |||
| b1c4f8e7d7 | |||
| feaae7de28 | |||
| c2d76b5466 | |||
| d4b9d16073 | |||
| 094a35e435 | |||
| 697481fd29 | |||
| bb6f32a2ad | |||
| 335394f4b5 | |||
| 538c593b7b | |||
| 2dc8b30d92 | |||
| d54b17ba81 | |||
| cbf260d082 | |||
| 371d54a478 | |||
| e83b137be5 | |||
| eecb795a0e | |||
| 7dc1f86d71 | |||
| 38712e2607 | |||
| eb577ea4f3 | |||
| dd56a3a8a8 | |||
| aee5fbb8c4 | |||
| 85130958bd | |||
| a3f3e6a265 | |||
| b005c0a790 | |||
| 2ba8415039 | |||
| b1be35cc5f | |||
| 9ff925e31e | |||
| e64d2e2a55 | |||
| 2b6ace888d | |||
| 4b83987ea1 | |||
| c6cb66b597 | |||
| 64861f8142 | |||
| eb5f2b3648 | |||
| fcdf4a9172 | |||
| 1001eb8b5e | |||
| f0ce2dd50d | |||
| 1c2a1c1204 | |||
| 66bdfff454 | |||
| 1e50f8df80 | |||
| 736c41ecd6 |
@@ -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 || '' }}
|
||||
|
||||
@@ -87,6 +90,27 @@ jobs:
|
||||
shell: bash
|
||||
run: make test-lua
|
||||
|
||||
- name: Run version resolution tests
|
||||
shell: bash
|
||||
run: make test-version
|
||||
|
||||
- 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
|
||||
|
||||
+196
-113
@@ -2,9 +2,14 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/interchangable-ffi, feat/termux]
|
||||
branches: [main, fix/download-version]
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build-nvim:
|
||||
name: Build Neovim ${{ matrix.target }}
|
||||
@@ -62,7 +67,7 @@ jobs:
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -134,26 +139,26 @@ jobs:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-x64-gnu
|
||||
npm_package: fff-bin-linux-x64-gnu
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-arm64-gnu
|
||||
npm_package: fff-bin-linux-arm64-gnu
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-x64-musl
|
||||
npm_package: fff-bin-linux-x64-musl
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-arm64-musl
|
||||
npm_package: fff-bin-linux-arm64-musl
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
@@ -168,13 +173,13 @@ jobs:
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
|
||||
npm_package: fff-bun-darwin-x64
|
||||
npm_package: fff-bin-darwin-x64
|
||||
lib_filename: libfff_c.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_c.dylib
|
||||
npm_package: fff-bun-darwin-arm64
|
||||
npm_package: fff-bin-darwin-arm64
|
||||
lib_filename: libfff_c.dylib
|
||||
ext: dylib
|
||||
|
||||
@@ -182,18 +187,18 @@ jobs:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_c.dll
|
||||
npm_package: fff-bun-win32-x64
|
||||
npm_package: fff-bin-win32-x64
|
||||
lib_filename: fff_c.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_c.dll
|
||||
npm_package: fff-bun-win32-arm64
|
||||
npm_package: fff-bin-win32-arm64
|
||||
lib_filename: fff_c.dll
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -265,16 +270,105 @@ 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: mlugg/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: Install Lua
|
||||
uses: leafo/gh-actions-lua@v12
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -311,6 +405,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 +433,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: lua scripts/determine-version.lua
|
||||
|
||||
- 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 +456,67 @@ 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/fix/download-version' || startsWith(github.ref, 'refs/tags/v')))
|
||||
|| (github.event_name == 'pull_request' && github.head_ref == 'main')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Lua
|
||||
uses: leafo/gh-actions-lua@v12
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install cargo-edit
|
||||
run: cargo install cargo-edit
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: lua scripts/determine-version.lua
|
||||
|
||||
- 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/fix/download-version' || startsWith(github.ref, 'refs/tags/v')))
|
||||
|| (github.event_name == 'pull_request' && github.head_ref == 'main')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Lua
|
||||
uses: leafo/gh-actions-lua@v12
|
||||
|
||||
- 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: lua scripts/determine-version.lua
|
||||
|
||||
- name: Download npm package artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -397,94 +529,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
|
||||
npm run build
|
||||
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
|
||||
|
||||
|
||||
+11
-1
@@ -1,4 +1,5 @@
|
||||
doc/tags
|
||||
big-repo
|
||||
target/
|
||||
.archive.lua
|
||||
_*.lua
|
||||
@@ -10,6 +11,15 @@ result
|
||||
.repro/
|
||||
.wrangler/
|
||||
*.so
|
||||
big-repo/
|
||||
*.dylib
|
||||
# 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": "./target/release/fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1199
-548
File diff suppressed because it is too large
Load Diff
+6
-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.5.1", path = "crates/fff-grep" }
|
||||
fff-query-parser = { version = "0.5.1", path = "crates/fff-query-parser", default-features = false }
|
||||
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
bindet = "0.3"
|
||||
@@ -23,8 +27,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 +34,7 @@ mimalloc = "0.1.47"
|
||||
zlob = "1.3.0"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = "0.8.1"
|
||||
neo_frizbee = { version = "0.9.1", features = ["match_end_col"] }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.7"
|
||||
once_cell = "1.20.2"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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-version test-bun test-node prepare-bun prepare-node set-npm-version header
|
||||
|
||||
all: format test lint
|
||||
|
||||
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,12 +17,16 @@ 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 \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
|
||||
test-version: test-setup
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/version_spec.lua" 2>&1
|
||||
|
||||
prepare-bun: build
|
||||
mkdir -p packages/fff-bun/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
|
||||
@@ -25,14 +34,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-version 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,18 +294,19 @@ require('fff').setup({
|
||||
#### Available Methods
|
||||
|
||||
```lua
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').find_files() -- Find files in current repository
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file list
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
```
|
||||
|
||||
just jump to the definition and see what other APIs are exposed we have a plenty
|
||||
|
||||
#### Commands
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
@@ -301,10 +314,6 @@ FFF.nvim provides several commands for interacting with the file picker:
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
#### Multiline Paste Support
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Toggle scoring information display:
|
||||
@@ -365,6 +374,32 @@ require('fff').live_grep({ query = 'search term' })
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
|
||||
|
||||
#### Constraints
|
||||
|
||||
There are a number of constraints you can use to refine your search in both grep and file search mode:
|
||||
|
||||
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
|
||||
- `test/` - any deeply nested children of any test/ dir
|
||||
- `!something` - exclude results matching something
|
||||
- `!test/`, `!git:modified` - combining with any other constraint works as negation
|
||||
- `./**/*.{rs,lua}` - any valid glob expression via [the fastest globbing library](https://github.com/dmtrKovalenko/zlob)
|
||||
|
||||
For grep only:
|
||||
|
||||
- `*.md`, `*.{c,h}` - extension filtering
|
||||
- `src/main.rs` - grep in a single file
|
||||
|
||||
In addition to that, all constraints can be combined together like:
|
||||
|
||||
```
|
||||
git:modified src/**/*.rs !src/**/mod.rs user controller
|
||||
```
|
||||
|
||||
This will find all the files that qualify the constraints and:
|
||||
|
||||
- match **both** user and controller (for file mode)
|
||||
- match "user controller" (for grep mode)
|
||||
|
||||
#### Cross-Mode Suggestions
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search mode and displays the results as suggestions:
|
||||
@@ -458,41 +493,8 @@ Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
```vim
|
||||
```
|
||||
:FFFOpenLog
|
||||
```
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
|
||||
|
||||
#### Common Issues
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it's properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"files": {
|
||||
"includes": ["packages/**/*.ts", "!packages/*/dist"],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 90
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.5.2"
|
||||
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.5.1" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.5.2" }
|
||||
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 */
|
||||
+446
-261
@@ -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_str()),
|
||||
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,
|
||||
access_frecency_score: item.access_frecency_score as i64,
|
||||
modification_frecency_score: item.modification_frecency_score as i64,
|
||||
total_frecency_score: item.total_frecency_score() as i64,
|
||||
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,397 @@ 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_str()),
|
||||
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,
|
||||
total_frecency_score: file.total_frecency_score() as i64,
|
||||
access_frecency_score: file.access_frecency_score as i64,
|
||||
modification_frecency_score: file.modification_frecency_score as i64,
|
||||
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 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,
|
||||
pub is_watcher_ready: bool,
|
||||
pub is_warmup_complete: bool,
|
||||
}
|
||||
|
||||
impl From<fff::file_picker::ScanProgress> for FffScanProgress {
|
||||
fn from(p: fff::file_picker::ScanProgress) -> Self {
|
||||
Self {
|
||||
scanned_files_count: p.scanned_files_count as u64,
|
||||
is_scanning: p.is_scanning,
|
||||
is_watcher_ready: p.is_watcher_ready,
|
||||
is_warmup_complete: p.is_warmup_complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+536
-295
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.5.2"
|
||||
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"
|
||||
@@ -13,31 +14,32 @@ crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Call mi_collect(true) after large allocator churn (bigram build).
|
||||
# Requires mimalloc to be the global allocator (linked by fff-nvim).
|
||||
mimalloc-collect = ["dep:libmimalloc-sys"]
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
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.5.2" }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
libc = "0.2"
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
fff-grep = { workspace = true , version = "0.5.2" }
|
||||
aho-corasick = "1"
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
@@ -50,10 +52,13 @@ 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 }
|
||||
libmimalloc-sys = { version = "0.1", optional = true, features = ["extended"] }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
@@ -62,3 +67,15 @@ dunce = { workspace = true }
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
|
||||
[[bench]]
|
||||
name = "parse_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "bigram_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "memmem_bench"
|
||||
harness = false
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,129 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_search::bigram_filter::{BigramFilter, BigramIndexBuilder};
|
||||
|
||||
/// Build a realistic bigram index for benchmarking.
|
||||
/// Simulates a large repo by generating varied content per file.
|
||||
fn build_test_index(file_count: usize) -> BigramFilter {
|
||||
let builder = BigramIndexBuilder::new(file_count);
|
||||
let skip_builder = BigramIndexBuilder::new(file_count);
|
||||
|
||||
for i in 0..file_count {
|
||||
// Generate varied content so we get a mix of sparse and dense columns
|
||||
let content = format!(
|
||||
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // module {i}"
|
||||
);
|
||||
builder.add_file_content(&skip_builder, i, content.as_bytes());
|
||||
}
|
||||
|
||||
let mut index = builder.compress(None);
|
||||
let skip_index = skip_builder.compress(Some(12));
|
||||
index.set_skip_index(skip_index);
|
||||
index
|
||||
}
|
||||
|
||||
fn bench_bigram_query(c: &mut Criterion) {
|
||||
let file_counts = [10_000, 100_000, 500_000];
|
||||
|
||||
for &file_count in &file_counts {
|
||||
let index = build_test_index(file_count);
|
||||
eprintln!(
|
||||
"Index ({} files): {} columns",
|
||||
file_count,
|
||||
index.columns_used(),
|
||||
);
|
||||
|
||||
let mut group = c.benchmark_group(format!("bigram_query_{file_count}"));
|
||||
group.sample_size(500);
|
||||
|
||||
let queries: &[(&str, &[u8])] = &[
|
||||
("short_2char", b"st"),
|
||||
("medium_6char", b"struct"),
|
||||
("long_14char", b"let controller"),
|
||||
("multi_word", b"fn process"),
|
||||
];
|
||||
|
||||
for (name, query) in queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter(|| {
|
||||
let result = index.query(black_box(q));
|
||||
black_box(&result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_bigram_is_candidate(c: &mut Criterion) {
|
||||
let index = build_test_index(500_000);
|
||||
let candidates = match index.query(b"struct") {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// All bigrams ubiquitous at this size — skip candidate benches
|
||||
eprintln!("Skipping is_candidate bench: query returned None (all bigrams ubiquitous)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
c.bench_function("is_candidate_500k", |b| {
|
||||
b.iter(|| {
|
||||
let mut count = 0u32;
|
||||
for i in 0..500_000 {
|
||||
if BigramFilter::is_candidate(black_box(&candidates), i) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
black_box(count)
|
||||
});
|
||||
});
|
||||
|
||||
c.bench_function("count_candidates_500k", |b| {
|
||||
b.iter(|| BigramFilter::count_candidates(black_box(&candidates)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_bigram_build(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("bigram_build");
|
||||
group.sample_size(10);
|
||||
|
||||
let file_counts = [10_000, 100_000];
|
||||
|
||||
for &file_count in &file_counts {
|
||||
// Pre-generate content so we only measure index building
|
||||
let contents: Vec<String> = (0..file_count)
|
||||
.map(|i| {
|
||||
format!(
|
||||
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // mod {i}"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("build_and_compress", file_count),
|
||||
&file_count,
|
||||
|b, &fc| {
|
||||
b.iter(|| {
|
||||
let builder = BigramIndexBuilder::new(fc);
|
||||
let skip_builder = BigramIndexBuilder::new(fc);
|
||||
for (i, content) in contents.iter().enumerate() {
|
||||
builder.add_file_content(&skip_builder, i, content.as_bytes());
|
||||
}
|
||||
let index = builder.compress(None);
|
||||
black_box(index.columns_used())
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_bigram_query,
|
||||
bench_bigram_is_candidate,
|
||||
bench_bigram_build,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,101 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_search::case_insensitive_memmem;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load real source files from the repository as benchmark haystacks.
|
||||
/// Falls back to concatenating all .rs files under crates/ if specific files are missing.
|
||||
fn load_real_files() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR"); // crates/fff-core
|
||||
let repo_root = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
|
||||
let files: &[(&str, &str)] = &[
|
||||
("grep.rs/80KB", "crates/fff-core/src/grep.rs"),
|
||||
("file_picker.rs/53KB", "crates/fff-core/src/file_picker.rs"),
|
||||
("picker_ui.lua/96KB", "lua/fff/picker_ui.lua"),
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
for &(label, rel_path) in files {
|
||||
let full_path = repo_root.join(rel_path);
|
||||
if let Ok(data) = std::fs::read(&full_path) {
|
||||
result.push((label, data));
|
||||
}
|
||||
}
|
||||
|
||||
// Also create a large synthetic file by concatenating all three
|
||||
if result.len() == 3 {
|
||||
let mut combined = Vec::new();
|
||||
for (_, data) in &result {
|
||||
combined.extend_from_slice(data);
|
||||
}
|
||||
// Repeat to get ~1MB
|
||||
let base = combined.clone();
|
||||
while combined.len() < 1024 * 1024 {
|
||||
combined.extend_from_slice(&base);
|
||||
}
|
||||
combined.truncate(1024 * 1024);
|
||||
result.push(("combined/1MB", combined));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn bench_memmem(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("case_insensitive_memmem");
|
||||
|
||||
let files = load_real_files();
|
||||
assert!(!files.is_empty(), "No source files found for benchmarking");
|
||||
|
||||
// Needles chosen to exercise different false-positive rates:
|
||||
//
|
||||
// "hit" needles: strings that actually appear in these source files.
|
||||
// "miss" needles: strings with common first-bytes (lots of false positives
|
||||
// for memchr2) but that don't exist in any of the files.
|
||||
let needles: &[(&str, &[u8])] = &[
|
||||
// Hits — real identifiers from the codebase
|
||||
("short/hit/fn", b"fn"),
|
||||
("short/hit/self", b"self"),
|
||||
("medium/hit", b"search_file"),
|
||||
("long/hit", b"content_cache_budget"),
|
||||
// Misses — common first-bytes, guaranteed not in source
|
||||
("short/miss", b"zqxjv"),
|
||||
("medium/miss", b"fluxcapacitor"),
|
||||
("long/miss", b"quantum_entanglement_resolver"),
|
||||
];
|
||||
|
||||
for (file_label, haystack) in &files {
|
||||
for &(needle_label, needle) in needles {
|
||||
let needle_lower: Vec<u8> = needle.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
let id = format!("{file_label}/{needle_label}");
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("packed_pair", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search_packed_pair(h, n)));
|
||||
},
|
||||
);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("memchr2_search", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search(h, n)));
|
||||
},
|
||||
);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("scalar_baseline", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search_scalar(h, n)));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_memmem);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,180 @@
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
|
||||
use fff_query_parser::*;
|
||||
|
||||
fn bench_parse_simple(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_simple_text", |b| {
|
||||
b.iter(|| parser.parse(black_box("hello world")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_text_with_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("name *.rs")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_complex(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_complex_mixed", |b| {
|
||||
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_multiple_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_realistic_queries(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let queries = vec![
|
||||
"file",
|
||||
"test",
|
||||
"mod.rs",
|
||||
"src/*.rs",
|
||||
"lib test",
|
||||
"*.rs !test",
|
||||
"src/lib/*.rs",
|
||||
"/src/ name",
|
||||
"status:modified *.rs",
|
||||
"type:rust test !node_modules",
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("realistic_queries");
|
||||
for query in queries.iter() {
|
||||
group.throughput(Throughput::Bytes(query.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_parse_various_lengths(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let short = "*.rs";
|
||||
let medium = "src name *.rs !test";
|
||||
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
|
||||
let very_long =
|
||||
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
|
||||
|
||||
let mut group = c.benchmark_group("query_lengths");
|
||||
|
||||
group.throughput(Throughput::Bytes(short.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(medium.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(long.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(very_long.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("very_long", very_long.len()),
|
||||
&very_long,
|
||||
|b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_config_comparison(c: &mut Criterion) {
|
||||
let file_picker = QueryParser::new(FileSearchConfig);
|
||||
let grep = QueryParser::new(GrepConfig);
|
||||
|
||||
let query = "src name *.rs !test";
|
||||
|
||||
let mut group = c.benchmark_group("config_comparison");
|
||||
|
||||
group.bench_function("file_picker_config", |b| {
|
||||
b.iter(|| file_picker.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.bench_function("grep_config", |b| {
|
||||
b.iter(|| grep.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_constraint_types(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let mut group = c.benchmark_group("constraint_types");
|
||||
|
||||
group.bench_function("extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("exclude", |b| {
|
||||
b.iter(|| parser.parse(black_box("!test")));
|
||||
});
|
||||
|
||||
group.bench_function("path_segment", |b| {
|
||||
b.iter(|| parser.parse(black_box("/src/")));
|
||||
});
|
||||
|
||||
group.bench_function("git_status", |b| {
|
||||
b.iter(|| parser.parse(black_box("status:modified")));
|
||||
});
|
||||
|
||||
group.bench_function("file_type", |b| {
|
||||
b.iter(|| parser.parse(black_box("type:rust")));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_worst_case(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
// Worst case: many constraints that all need to be checked
|
||||
let worst_case = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
|
||||
|
||||
c.bench_function("worst_case_many_text_tokens", |b| {
|
||||
b.iter(|| parser.parse(black_box(worst_case)));
|
||||
});
|
||||
|
||||
// Many constraints
|
||||
let many_constraints = "*.rs *.toml *.md *.txt *.js *.ts *.jsx *.tsx *.vue *.svelte";
|
||||
|
||||
c.bench_function("worst_case_many_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box(many_constraints)));
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_parse_simple,
|
||||
bench_parse_complex,
|
||||
bench_parse_realistic_queries,
|
||||
bench_parse_various_lengths,
|
||||
bench_config_comparison,
|
||||
bench_constraint_types,
|
||||
bench_worst_case,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -1,26 +1,45 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::file_picker::{FFFMode, FilePicker};
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::shared::{SharedFrecency, SharedPicker};
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, 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) => {
|
||||
@@ -86,7 +134,7 @@ impl BackgroundWatcher {
|
||||
// directories like `target/` in rust causes buffer overflow, which drops real source file
|
||||
// events. Instead we watch the root non-recursively (for top-level file changes
|
||||
// and new directory detection) and each non-ignored subdirectory recursively.
|
||||
let watch_dirs = collect_non_ignored_dirs(&base_path);
|
||||
let watch_dirs = collect_non_ignored_dirs(&base_path, git_workdir.is_some());
|
||||
|
||||
if watch_dirs.len() > MAX_SELECTIVE_WATCH_DIRS {
|
||||
tracing::warn!(
|
||||
@@ -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());
|
||||
@@ -286,9 +333,9 @@ fn handle_debounced_events(
|
||||
debug!(
|
||||
"on_create_or_modify({:?}) -> Some({})",
|
||||
path,
|
||||
file.path.display()
|
||||
file.path_str()
|
||||
);
|
||||
files_to_update.push(file.path.clone());
|
||||
files_to_update.push(PathBuf::from(file.path_str()));
|
||||
}
|
||||
None => {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
@@ -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");
|
||||
@@ -325,7 +416,7 @@ fn handle_debounced_events(
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
|
||||
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
|
||||
let result = shared_picker.refresh_git_status(shared_frecency);
|
||||
if let Err(e) = result {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
@@ -388,14 +479,21 @@ fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there is a git repo, respect its ignore rules.
|
||||
// If there is no repo (or the check fails), include the file.
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) != Ok(true),
|
||||
None => true,
|
||||
None => {
|
||||
// No git repo — apply basic sanity filters.
|
||||
// Hidden directories are skipped by the watcher setup (hidden(true)),
|
||||
// but events can still arrive for files in known non-code directories.
|
||||
!is_non_code_directory(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_code_directory(path: &Path) -> bool {
|
||||
crate::ignore::is_non_code_directory(path)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_git_file(path: &Path) -> bool {
|
||||
path.components()
|
||||
@@ -481,18 +579,25 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
|
||||
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
|
||||
/// selective file watching — only non-ignored directories get a recursive watcher,
|
||||
/// preventing gitignored directories like `target/` from flooding the OS event buffer.
|
||||
fn collect_non_ignored_dirs(base_path: &Path) -> Vec<PathBuf> {
|
||||
fn collect_non_ignored_dirs(base_path: &Path, has_git_repo: bool) -> Vec<PathBuf> {
|
||||
use crate::ignore::non_git_repo_overrides;
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let walker = WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
let mut walk_builder = WalkBuilder::new(base_path);
|
||||
walk_builder
|
||||
.hidden(!has_git_repo)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.max_depth(Some(1))
|
||||
.build();
|
||||
.max_depth(Some(1));
|
||||
|
||||
if !has_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) {
|
||||
walk_builder.overrides(overrides);
|
||||
}
|
||||
|
||||
let walker = walk_builder.build();
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
for entry in walker {
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use ahash::AHashMap;
|
||||
|
||||
/// Maximum number of distinct bigrams tracked in the inverted index.
|
||||
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
|
||||
/// We cap at 5000 to cover all printable bigrams with margin.
|
||||
/// 5000 columns × 62.5KB (500k files) = 305MB. For 50k files: 30MB.
|
||||
const MAX_BIGRAM_COLUMNS: usize = 5000;
|
||||
|
||||
/// Sentinel value: bigram has no allocated column.
|
||||
const NO_COLUMN: u16 = u16::MAX;
|
||||
|
||||
/// Temporary sync dense builder for the bigram index.
|
||||
/// Builds from the many threads reading file contents in parallel
|
||||
pub struct BigramIndexBuilder {
|
||||
// we use lookup as atomics only in the builder because it is filled by the rayon threads
|
||||
// the actual index uses pure u16 for the allocations
|
||||
lookup: Vec<AtomicU16>,
|
||||
/// Per-column bitset data, lazily allocated via OnceLock.
|
||||
col_data: Vec<AtomicU64>,
|
||||
next_column: AtomicU16,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: AtomicUsize,
|
||||
}
|
||||
|
||||
impl BigramIndexBuilder {
|
||||
pub fn new(file_count: usize) -> Self {
|
||||
let words = file_count.div_ceil(64);
|
||||
let mut lookup = Vec::with_capacity(65536);
|
||||
lookup.resize_with(65536, || AtomicU16::new(NO_COLUMN));
|
||||
let mut col_data = Vec::with_capacity(MAX_BIGRAM_COLUMNS * words);
|
||||
col_data.resize_with(MAX_BIGRAM_COLUMNS * words, || AtomicU64::new(0));
|
||||
Self {
|
||||
lookup,
|
||||
col_data,
|
||||
next_column: AtomicU16::new(0),
|
||||
words,
|
||||
file_count,
|
||||
populated: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_or_alloc_column(&self, key: u16) -> u16 {
|
||||
let current = self.lookup[key as usize].load(Ordering::Relaxed);
|
||||
if current != NO_COLUMN {
|
||||
return current;
|
||||
}
|
||||
let new_col = self.next_column.fetch_add(1, Ordering::Relaxed);
|
||||
if new_col >= MAX_BIGRAM_COLUMNS as u16 {
|
||||
return NO_COLUMN;
|
||||
}
|
||||
|
||||
match self.lookup[key as usize].compare_exchange(
|
||||
NO_COLUMN,
|
||||
new_col,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => new_col,
|
||||
Err(existing) => existing,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn column_bitset(&self, col: u16) -> &[AtomicU64] {
|
||||
let start = col as usize * self.words;
|
||||
&self.col_data[start..start + self.words]
|
||||
}
|
||||
|
||||
pub(crate) fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
|
||||
if content.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
debug_assert!(file_idx < self.file_count);
|
||||
let word_idx = file_idx / 64;
|
||||
let bit_mask = 1u64 << (file_idx % 64);
|
||||
|
||||
// Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 bigrams with margin
|
||||
// have to fit in L1 cache
|
||||
let mut seen_consec = [0u64; 1024];
|
||||
let mut seen_skip = [0u64; 1024];
|
||||
|
||||
let bytes = content;
|
||||
let len = bytes.len();
|
||||
|
||||
// First consecutive pair (no skip bigram possible yet).
|
||||
let (a, b) = (bytes[0], bytes[1]);
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let w = key as usize >> 6;
|
||||
let bit = 1u64 << (key as usize & 63);
|
||||
seen_consec[w] |= bit;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// Main loop: consecutive (i-1, i) and skip-1 (i-2, i)
|
||||
for i in 2..len {
|
||||
let cur = bytes[i];
|
||||
|
||||
// Consecutive bigram: (bytes[i-1], bytes[i])
|
||||
let prev = bytes[i - 1];
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&cur) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16;
|
||||
let w = key as usize >> 6;
|
||||
let bit = 1u64 << (key as usize & 63);
|
||||
if seen_consec[w] & bit == 0 {
|
||||
seen_consec[w] |= bit;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip-1 bigram: (bytes[i-2], bytes[i])
|
||||
let skip_prev = bytes[i - 2];
|
||||
if (32..=126).contains(&skip_prev) && (32..=126).contains(&cur) {
|
||||
let key =
|
||||
(skip_prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16;
|
||||
let w = key as usize >> 6;
|
||||
let bit = 1u64 << (key as usize & 63);
|
||||
if seen_skip[w] & bit == 0 {
|
||||
seen_skip[w] |= bit;
|
||||
let col = skip_builder.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
skip_builder.column_bitset(col)[word_idx]
|
||||
.fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.populated.fetch_add(1, Ordering::Relaxed);
|
||||
skip_builder.populated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> u16 {
|
||||
self.next_column
|
||||
.load(Ordering::Relaxed)
|
||||
.min(MAX_BIGRAM_COLUMNS as u16)
|
||||
}
|
||||
|
||||
/// Compress the dense builder into a compact `BigramFilter`.
|
||||
///
|
||||
/// Retains columns where the bigram appears in ≥`min_density_pct`% (or
|
||||
/// the default ~3.1% heuristic when `None`) and <90% of indexed files.
|
||||
/// Sparse columns carry too little data to justify their memory;
|
||||
/// ubiquitous columns (≥90%) are nearly all-ones and barely filter.
|
||||
pub fn compress(self, min_density_pct: Option<u32>) -> BigramFilter {
|
||||
let cols = self.columns_used() as usize;
|
||||
let words = self.words;
|
||||
let file_count = self.file_count;
|
||||
let populated = self.populated.load(Ordering::Relaxed);
|
||||
let dense_bytes = words * 8; // cost of one dense column
|
||||
|
||||
let old_lookup = self.lookup;
|
||||
let col_data = self.col_data;
|
||||
|
||||
let mut lookup: Vec<u16> = vec![NO_COLUMN; 65536];
|
||||
let mut dense_data: Vec<u64> = Vec::with_capacity(cols * words);
|
||||
let mut dense_count: usize = 0;
|
||||
|
||||
for key in 0..65536usize {
|
||||
let old_col = old_lookup[key].load(Ordering::Relaxed);
|
||||
if old_col == NO_COLUMN || old_col as usize >= cols {
|
||||
continue;
|
||||
}
|
||||
|
||||
let col_start = old_col as usize * words;
|
||||
let bitset = &col_data[col_start..col_start + words];
|
||||
|
||||
// count set bits to decide if this column is worth keeping.
|
||||
let mut popcount = 0u32;
|
||||
for column in bitset.iter().take(words) {
|
||||
popcount += column.load(Ordering::Relaxed).count_ones();
|
||||
}
|
||||
|
||||
// drop bigrams appearing in too few files
|
||||
let not_to_rare = if let Some(min_pct) = min_density_pct {
|
||||
// Percentage-based: require ≥ min_pct% of populated files.
|
||||
populated > 0 && (popcount as usize) * 100 >= populated * min_pct as usize
|
||||
} else {
|
||||
// Default: popcount ≥ words × 2 (~3.1% of files).
|
||||
(popcount as usize * 4) >= dense_bytes
|
||||
};
|
||||
|
||||
if !not_to_rare {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop ubiquitous bigrams — columns ≥90% ones carry almost no
|
||||
// filtering power and just waste memory + AND cycles.
|
||||
if populated > 0 && (popcount as usize) * 10 >= populated * 9 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dense_idx = dense_count as u16;
|
||||
lookup[key] = dense_idx;
|
||||
dense_count += 1;
|
||||
|
||||
for column in bitset.iter().take(words) {
|
||||
dense_data.push(column.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
// col_data + old_lookup dropped here — single deallocation each,
|
||||
// no fragmentation.
|
||||
|
||||
BigramFilter {
|
||||
lookup,
|
||||
dense_data,
|
||||
dense_count,
|
||||
words,
|
||||
file_count,
|
||||
populated,
|
||||
skip_index: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for BigramIndexBuilder {}
|
||||
unsafe impl Sync for BigramIndexBuilder {}
|
||||
|
||||
/// Inverted bigram index with optional "skip-1" extension
|
||||
/// Copmressed into bitset for minimal usage, the layout of this struct actually matters
|
||||
#[derive(Debug)]
|
||||
pub struct BigramFilter {
|
||||
lookup: Vec<u16>,
|
||||
/// Flat buffer of all dense column data laid out at fixed stride `words`.
|
||||
/// Column `i` starts at `i * words`.
|
||||
dense_data: Vec<u64>, // do not try to change this to u8 it has to be wordsize
|
||||
dense_count: usize,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: usize,
|
||||
/// Optional skip-1 bigram index (stride 2). Built from character pairs
|
||||
/// at distance 2, e.g. "ABCDE" → (A,C),(B,D),(C,E). ANDead with the
|
||||
/// consecutive bigram candidates during query to dramatically reduce
|
||||
/// false positives.
|
||||
skip_index: Option<Box<BigramFilter>>,
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise AND of two equal-length bitsets.
|
||||
// Auto vectorized (don't touch)
|
||||
#[inline]
|
||||
fn bitset_and(result: &mut [u64], bitset: &[u64]) {
|
||||
result
|
||||
.iter_mut()
|
||||
.zip(bitset.iter())
|
||||
.for_each(|(r, b)| *r &= *b);
|
||||
}
|
||||
|
||||
impl BigramFilter {
|
||||
/// AND the posting lists for all query bigrams (consecutive + skip).
|
||||
/// Returns None if no query bigrams are tracked.
|
||||
pub fn query(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
if pattern.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
let mut prev = pattern[0];
|
||||
for &b in &pattern[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
// SAFETY: compress() guarantees offset + words <= dense_data.len()
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
|
||||
// strid-1 bigrams
|
||||
if let Some(skip) = &self.skip_index
|
||||
&& pattern.len() >= 3
|
||||
&& let Some(skip_candidates) = skip.query_skip(pattern)
|
||||
{
|
||||
bitset_and(&mut result, &skip_candidates);
|
||||
has_filter = true;
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Query using stride-2 bigrams from the pattern.
|
||||
/// For "ABCDE" queries with keys (A,C), (B,D), (C,E).
|
||||
fn query_skip(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
for i in 0..pattern.len().saturating_sub(2) {
|
||||
let a = pattern[i];
|
||||
let b = pattern[i + 2];
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Attach a skip-1 bigram index for tighter candidate filtering.
|
||||
pub fn set_skip_index(&mut self, skip: BigramFilter) {
|
||||
self.skip_index = Some(Box::new(skip));
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_candidate(candidates: &[u64], file_idx: usize) -> bool {
|
||||
let word = file_idx / 64;
|
||||
let bit = file_idx % 64;
|
||||
word < candidates.len() && candidates[word] & (1u64 << bit) != 0
|
||||
}
|
||||
|
||||
pub fn count_candidates(candidates: &[u64]) -> usize {
|
||||
candidates.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated > 0
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.file_count
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> usize {
|
||||
self.dense_count
|
||||
}
|
||||
|
||||
/// Total heap bytes used by this index (lookup + dense data + skip).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let lookup_bytes = self.lookup.len() * std::mem::size_of::<u16>();
|
||||
let dense_bytes = self.dense_data.len() * std::mem::size_of::<u64>();
|
||||
let skip_bytes = self.skip_index.as_ref().map_or(0, |s| s.heap_bytes());
|
||||
lookup_bytes + dense_bytes + skip_bytes
|
||||
}
|
||||
|
||||
/// Check whether a bigram key is present in this index.
|
||||
pub fn has_key(&self, key: u16) -> bool {
|
||||
self.lookup[key as usize] != NO_COLUMN
|
||||
}
|
||||
|
||||
/// Raw lookup table (65536 entries mapping bigram key → column index).
|
||||
pub fn lookup(&self) -> &[u16] {
|
||||
&self.lookup
|
||||
}
|
||||
|
||||
/// Flat dense bitset data at fixed stride `words`.
|
||||
pub fn dense_data(&self) -> &[u64] {
|
||||
&self.dense_data
|
||||
}
|
||||
|
||||
/// Number of u64 words per column (= ceil(file_count / 64)).
|
||||
pub fn words(&self) -> usize {
|
||||
self.words
|
||||
}
|
||||
|
||||
/// Number of dense columns retained after compression.
|
||||
pub fn dense_count(&self) -> usize {
|
||||
self.dense_count
|
||||
}
|
||||
|
||||
/// Number of files that contributed content to the index.
|
||||
pub fn populated(&self) -> usize {
|
||||
self.populated
|
||||
}
|
||||
|
||||
/// Reference to the optional skip-1 bigram sub-index.
|
||||
pub fn skip_index(&self) -> Option<&BigramFilter> {
|
||||
self.skip_index.as_deref()
|
||||
}
|
||||
|
||||
/// Create a new bigram filter from the internal data
|
||||
pub fn new(
|
||||
lookup: Vec<u16>,
|
||||
dense_data: Vec<u64>,
|
||||
dense_count: usize,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
lookup,
|
||||
dense_data,
|
||||
dense_count,
|
||||
words,
|
||||
file_count,
|
||||
populated,
|
||||
skip_index: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_bigrams(content: &[u8]) -> Vec<u16> {
|
||||
if content.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Use a flat bitset (65536 bits = 8 KB) for dedup — faster than HashSet.
|
||||
let mut seen = vec![0u64; 1024]; // 1024 * 64 = 65536 bits
|
||||
let mut bigrams = Vec::new();
|
||||
|
||||
let mut prev = content[0];
|
||||
for &b in &content[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let word = key as usize / 64;
|
||||
let bit = 1u64 << (key as usize % 64);
|
||||
if seen[word] & bit == 0 {
|
||||
seen[word] |= bit;
|
||||
bigrams.push(key);
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
bigrams
|
||||
}
|
||||
|
||||
/// Modified and added files store their own bigram sets. Deleted files are
|
||||
/// tombstoned in a bitset so they can be excluded from base query results.
|
||||
/// This overlay is updated by the background watcher on every file event
|
||||
/// and cleared when the base index is rebuilt.
|
||||
#[derive(Debug)]
|
||||
pub struct BigramOverlay {
|
||||
/// Per-file bigram sets for files modified since the base was built.
|
||||
/// Key = file index in the base `Vec<FileItem>`.
|
||||
modified: AHashMap<usize, Vec<u16>>,
|
||||
|
||||
/// Tombstone bitset — one bit per base file. Set bits are excluded
|
||||
/// from base query results.
|
||||
tombstones: Vec<u64>,
|
||||
|
||||
/// Original files count this overlay was created for.
|
||||
base_file_count: usize,
|
||||
}
|
||||
|
||||
impl BigramOverlay {
|
||||
pub(crate) fn new(base_file_count: usize) -> Self {
|
||||
let words = base_file_count.div_ceil(64);
|
||||
Self {
|
||||
modified: AHashMap::new(),
|
||||
tombstones: vec![0u64; words],
|
||||
base_file_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn modify_file(&mut self, file_idx: usize, content: &[u8]) {
|
||||
self.modified.insert(file_idx, extract_bigrams(content));
|
||||
}
|
||||
|
||||
pub(crate) fn delete_file(&mut self, file_idx: usize) {
|
||||
if file_idx < self.base_file_count {
|
||||
let word = file_idx / 64;
|
||||
self.tombstones[word] |= 1u64 << (file_idx % 64);
|
||||
}
|
||||
self.modified.remove(&file_idx);
|
||||
}
|
||||
|
||||
/// Return base file indices of modified files whose bigrams match ALL
|
||||
/// of the given `pattern_bigrams`.
|
||||
pub(crate) fn query_modified(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
|
||||
if pattern_bigrams.is_empty() {
|
||||
return self.modified.keys().copied().collect();
|
||||
}
|
||||
self.modified
|
||||
.iter()
|
||||
.filter_map(|(&file_idx, bigrams)| {
|
||||
pattern_bigrams
|
||||
.iter()
|
||||
.all(|pb| bigrams.contains(pb))
|
||||
.then_some(file_idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of base files this overlay was created for.
|
||||
pub(crate) fn base_file_count(&self) -> usize {
|
||||
self.base_file_count
|
||||
}
|
||||
|
||||
/// Get the tombstone bitset for clearing base candidates.
|
||||
pub(crate) fn tombstones(&self) -> &[u64] {
|
||||
&self.tombstones
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
//! SIMD-accelerated case-insensitive substring search.
|
||||
//!
|
||||
//! Implementations (fastest → simplest):
|
||||
//! - `search_packed_pair`: AVX2 packed-pair scan (two rare bytes at known offsets)
|
||||
//! - `search`: memchr2 first-byte scan + verify
|
||||
//!
|
||||
//! The packed-pair approach mirrors what `memchr::memmem` does internally for
|
||||
//! case-sensitive search — pick two rare bytes from the needle, SIMD-scan for
|
||||
//! both simultaneously, verify candidates. This gives quadratic selectivity
|
||||
//! over the single-byte memchr2 approach.
|
||||
|
||||
// this is stolen from the memchr2 crate
|
||||
const BYTE_FREQUENCIES: [u8; 256] = [
|
||||
55, 52, 51, 50, 49, 48, 47, 46, 45, 103, 242, 66, 67, 229, 44, 43, // 0x00
|
||||
42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 56, 32, 31, 30, 29, 28, // 0x10
|
||||
255, 148, 164, 149, 136, 160, 155, 173, 221, 222, 134, 122, 232, 202, 215, 224, // 0x20
|
||||
208, 220, 204, 187, 183, 179, 177, 168, 178, 200, 226, 195, 154, 184, 174, 126, // 0x30
|
||||
120, 191, 157, 194, 170, 189, 162, 161, 150, 193, 142, 137, 171, 176, 185,
|
||||
167, // 0x40 A-O
|
||||
186, 112, 175, 192, 188, 156, 140, 143, 123, 133, 128, 147, 138, 146, 114,
|
||||
223, // 0x50 P-_
|
||||
151, 249, 216, 238, 236, 253, 227, 218, 230, 247, 135, 180, 241, 233, 246,
|
||||
244, // 0x60 a-o
|
||||
231, 139, 245, 243, 251, 235, 201, 196, 240, 214, 152, 182, 205, 181, 127,
|
||||
27, // 0x70 p-DEL
|
||||
212, 211, 210, 213, 228, 197, 169, 159, 131, 172, 105, 80, 98, 96, 97, 81, // 0x80
|
||||
207, 145, 116, 115, 144, 130, 153, 121, 107, 132, 109, 110, 124, 111, 82, 108, // 0x90
|
||||
118, 141, 113, 129, 119, 125, 165, 117, 92, 106, 83, 72, 99, 93, 65, 79, // 0xa0
|
||||
166, 237, 163, 199, 190, 225, 209, 203, 198, 217, 219, 206, 234, 248, 158, 239, // 0xb0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xc0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xd0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xe0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xf0
|
||||
];
|
||||
|
||||
#[inline]
|
||||
fn ascii_fold_byte(b: u8) -> u8 {
|
||||
if b.is_ascii_uppercase() { b | 0x20 } else { b }
|
||||
}
|
||||
|
||||
/// Toggle ASCII letter case by flipping bit 5.
|
||||
/// `'n' → 'N'`, `'N' → 'n'`.
|
||||
#[inline]
|
||||
fn ascii_swap_case(b: u8) -> u8 {
|
||||
b ^ 0x20
|
||||
}
|
||||
|
||||
/// Effective frequency rank for a case-insensitive byte position.
|
||||
/// Takes the max of lower/upper ranks because we must scan for both.
|
||||
#[inline]
|
||||
fn case_insensitive_rank(lower: u8) -> u8 {
|
||||
if lower.is_ascii_lowercase() {
|
||||
let upper = ascii_swap_case(lower);
|
||||
BYTE_FREQUENCIES[lower as usize].max(BYTE_FREQUENCIES[upper as usize])
|
||||
} else {
|
||||
BYTE_FREQUENCIES[lower as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick two needle positions with the rarest bytes (case-insensitive).
|
||||
/// Returns (index1, index2) where index1 <= index2.
|
||||
fn select_rare_pair(needle_lower: &[u8]) -> (usize, usize) {
|
||||
debug_assert!(needle_lower.len() >= 2);
|
||||
|
||||
let mut best1 = (u8::MAX, 0usize); // (rank, position)
|
||||
let mut best2 = (u8::MAX, 1usize);
|
||||
|
||||
for (i, &b) in needle_lower.iter().enumerate() {
|
||||
let r = case_insensitive_rank(b);
|
||||
if r < best1.0 {
|
||||
best2 = best1;
|
||||
best1 = (r, i);
|
||||
} else if r < best2.0 && i != best1.1 {
|
||||
best2 = (r, i);
|
||||
}
|
||||
}
|
||||
|
||||
let i1 = best1.1.min(best2.1);
|
||||
let i2 = best1.1.max(best2.1);
|
||||
(i1, i2)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn verify_scalar(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
for (i, _) in needle_lower.iter().enumerate() {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// AVX2 case-insensitive verify: checks whether `needle_lower` matches
|
||||
/// the haystack bytes starting at `h`, treating ASCII uppercase as lowercase.
|
||||
///
|
||||
/// Processes 32 bytes at a time using a SIMD trick: AVX2 only has a
|
||||
/// **signed** byte compare (`cmpgt`), but we need an **unsigned** range
|
||||
/// check (`'A' <= byte <= 'Z'`). The trick is to XOR every byte with
|
||||
/// `0x80`, which maps the unsigned range `[0, 255]` into the signed range
|
||||
/// `[-128, 127]` while preserving order. After the flip, signed `cmpgt`
|
||||
/// gives correct unsigned comparisons.
|
||||
///
|
||||
/// Once we know which bytes are uppercase, we set bit 5 (`0x20`) on them
|
||||
/// — this converts `'A'..'Z'` to `'a'..'z'` — then compare against the
|
||||
/// pre-lowered needle.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn verify_avx2(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
use core::arch::x86_64::*;
|
||||
|
||||
let len = needle_lower.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
// Broadcast constants used every iteration:
|
||||
//
|
||||
// flip = 0x80 in every lane — XOR converts unsigned→signed domain
|
||||
// a_minus_1 = ('A' - 1) ^ 0x80 — lower bound for the range check (signed)
|
||||
// z_plus_1 = ('Z' + 1) ^ 0x80 — upper bound for the range check (signed)
|
||||
// bit20 = 0x20 in every lane — OR this onto uppercase bytes to lowercase them
|
||||
let flip = _mm256_set1_epi8(0x80u8 as i8);
|
||||
let a_minus_1 = _mm256_set1_epi8((b'A' - 1) as i8 ^ 0x80u8 as i8);
|
||||
let z_plus_1 = _mm256_set1_epi8((b'Z' + 1) as i8 ^ 0x80u8 as i8);
|
||||
let bit20 = _mm256_set1_epi8(0x20u8 as i8);
|
||||
|
||||
while i + 32 <= len {
|
||||
// Load 32 bytes from the haystack candidate position.
|
||||
let hv = unsafe { _mm256_loadu_si256(h.add(i) as *const __m256i) };
|
||||
// Load 32 bytes from the pre-lowercased needle.
|
||||
let nv = unsafe { _mm256_loadu_si256(needle_lower.as_ptr().add(i) as *const __m256i) };
|
||||
|
||||
// Flip into signed domain: x = hv ^ 0x80.
|
||||
// After this, unsigned ordering is preserved under signed compare.
|
||||
let x = _mm256_xor_si256(hv, flip);
|
||||
|
||||
// ge_a[lane] = 0xFF if x[lane] > a_minus_1, i.e. hv[lane] >= 'A' (unsigned).
|
||||
let ge_a = _mm256_cmpgt_epi8(x, a_minus_1);
|
||||
// le_z[lane] = 0xFF if z_plus_1 > x[lane], i.e. hv[lane] <= 'Z' (unsigned).
|
||||
let le_z = _mm256_cmpgt_epi8(z_plus_1, x);
|
||||
// upper[lane] = 0xFF only for bytes in the range 'A'..='Z'.
|
||||
let upper = _mm256_and_si256(ge_a, le_z);
|
||||
|
||||
// Case-fold: set bit 5 on uppercase bytes → converts 'A'..'Z' to 'a'..'z'.
|
||||
// Non-letter bytes are untouched because their `upper` lane is 0x00.
|
||||
let folded = _mm256_or_si256(hv, _mm256_and_si256(upper, bit20));
|
||||
|
||||
// Compare the folded haystack against the lowercase needle.
|
||||
let eq = _mm256_cmpeq_epi8(folded, nv);
|
||||
// movemask extracts the high bit of each lane into a 32-bit mask.
|
||||
// All-equal → all high bits set → mask == 0xFFFFFFFF == -1i32.
|
||||
if _mm256_movemask_epi8(eq) != -1i32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
i += 32;
|
||||
}
|
||||
|
||||
// Scalar tail: handle remaining bytes that don't fill a full 32-byte vector.
|
||||
while i < len {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ======== NEON + dotprod (aarch64) ===========================================
|
||||
|
||||
/// Extract a 16-bit bitmask from a NEON comparison result (each byte 0x00 or 0xFF).
|
||||
/// Bit *i* of the result corresponds to byte *i* of the input vector.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon")]
|
||||
#[inline]
|
||||
unsafe fn neon_movemask(v: core::arch::aarch64::uint8x16_t) -> u16 {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
// AND each byte with its bit-position mask, then horizontally sum each half.
|
||||
// Max possible sum per half = 1+2+4+8+16+32+64+128 = 255, fits in u8.
|
||||
static BITS: [u8; 16] = [1, 2, 4, 8, 16, 32, 64, 128, 1, 2, 4, 8, 16, 32, 64, 128];
|
||||
let bit_mask = unsafe { vld1q_u8(BITS.as_ptr()) };
|
||||
let masked = vandq_u8(v, bit_mask);
|
||||
let lo = vaddv_u8(vget_low_u8(masked));
|
||||
let hi = vaddv_u8(vget_high_u8(masked));
|
||||
(lo as u16) | ((hi as u16) << 8)
|
||||
}
|
||||
|
||||
/// NEON + dotprod case-insensitive verify.
|
||||
///
|
||||
/// Uses unsigned range checks (NEON has `vcge`/`vcle` for unsigned bytes
|
||||
/// no XOR-0x80 trick needed unlike AVX2) to detect uppercase ASCII, folds
|
||||
/// to lowercase, then checks equality via UDOT: XOR the folded haystack
|
||||
/// with the pre-lowered needle and dot-product the difference with itself.
|
||||
/// Any non-zero byte produces a non-zero u32 lane.
|
||||
///
|
||||
/// The UDOT instruction is emitted via inline asm because the `vdotq_u32`
|
||||
/// intrinsic is still behind an unstable feature gate on stable Rust.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon,dotprod")]
|
||||
unsafe fn verify_neon_dotprod(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
let len = needle_lower.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
let a_val = vdupq_n_u8(b'A');
|
||||
let z_val = vdupq_n_u8(b'Z');
|
||||
let bit20 = vdupq_n_u8(0x20);
|
||||
|
||||
while i + 16 <= len {
|
||||
let hv = unsafe { vld1q_u8(h.add(i)) };
|
||||
let nv = unsafe { vld1q_u8(needle_lower.as_ptr().add(i)) };
|
||||
|
||||
// Unsigned range check: 'A' <= byte <= 'Z'
|
||||
let upper = vandq_u8(vcgeq_u8(hv, a_val), vcleq_u8(hv, z_val));
|
||||
// Case-fold: set bit 5 on uppercase bytes → 'A'..'Z' → 'a'..'z'
|
||||
let folded = vorrq_u8(hv, vandq_u8(upper, bit20));
|
||||
|
||||
// XOR with needle — all-zero iff every byte matches.
|
||||
let xored = veorq_u8(folded, nv);
|
||||
|
||||
// UDOT: dot(xored, xored) sums squares of 4 consecutive byte
|
||||
// differences into each of the 4 u32 lanes (accumulates into zero).
|
||||
// Any non-zero byte produces a positive u32 contribution.
|
||||
let dots: uint32x4_t;
|
||||
let zero = vdupq_n_u32(0);
|
||||
unsafe {
|
||||
core::arch::asm!(
|
||||
"udot {d:v}.4s, {a:v}.16b, {b:v}.16b",
|
||||
d = inlateout(vreg) zero => dots,
|
||||
a = in(vreg) xored,
|
||||
b = in(vreg) xored,
|
||||
);
|
||||
}
|
||||
|
||||
if vmaxvq_u32(dots) != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
i += 16;
|
||||
}
|
||||
|
||||
// Scalar tail
|
||||
while i < len {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// NEON packed-pair kernel: scan 16 haystack positions per iteration,
|
||||
/// checking two rare bytes (case-insensitive) simultaneously.
|
||||
/// Same algorithm as the AVX2 version but with 128-bit vectors.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon")]
|
||||
unsafe fn search_packed_pair_neon(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> bool {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
let n = needle_lower.len();
|
||||
let hlen = haystack.len();
|
||||
let ptr = haystack.as_ptr();
|
||||
let last_start = hlen - n;
|
||||
|
||||
let b1 = needle_lower[i1];
|
||||
let b1_alt = if b1.is_ascii_lowercase() {
|
||||
ascii_swap_case(b1)
|
||||
} else {
|
||||
b1
|
||||
};
|
||||
let b2 = needle_lower[i2];
|
||||
let b2_alt = if b2.is_ascii_lowercase() {
|
||||
ascii_swap_case(b2)
|
||||
} else {
|
||||
b2
|
||||
};
|
||||
|
||||
let v1_lo = vdupq_n_u8(b1);
|
||||
let v1_hi = vdupq_n_u8(b1_alt);
|
||||
let v2_lo = vdupq_n_u8(b2);
|
||||
let v2_hi = vdupq_n_u8(b2_alt);
|
||||
|
||||
let max_idx = i1.max(i2);
|
||||
let max_offset = hlen.saturating_sub(max_idx + 16);
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset <= max_offset {
|
||||
let chunk1 = unsafe { vld1q_u8(ptr.add(offset + i1)) };
|
||||
let chunk2 = unsafe { vld1q_u8(ptr.add(offset + i2)) };
|
||||
|
||||
// Case-insensitive match: OR both case variants, then AND the two positions.
|
||||
let eq1 = vorrq_u8(vceqq_u8(chunk1, v1_lo), vceqq_u8(chunk1, v1_hi));
|
||||
let eq2 = vorrq_u8(vceqq_u8(chunk2, v2_lo), vceqq_u8(chunk2, v2_hi));
|
||||
|
||||
let mut mask = unsafe { neon_movemask(vandq_u8(eq1, eq2)) };
|
||||
|
||||
while mask != 0 {
|
||||
let bit = mask.trailing_zeros() as usize;
|
||||
let candidate = offset + bit;
|
||||
if candidate > last_start {
|
||||
return false;
|
||||
}
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 16;
|
||||
}
|
||||
|
||||
// Tail: remaining positions that couldn't fill a full vector.
|
||||
if offset <= last_start {
|
||||
let rare_pos =
|
||||
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
|
||||
i1
|
||||
} else {
|
||||
i2
|
||||
};
|
||||
let rare_byte = needle_lower[rare_pos];
|
||||
let tail_start = offset + rare_pos;
|
||||
let tail_end = last_start + rare_pos + 1;
|
||||
if tail_start < tail_end {
|
||||
let tail_space = &haystack[tail_start..tail_end];
|
||||
if rare_byte.is_ascii_lowercase() {
|
||||
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[inline]
|
||||
unsafe fn verify_dispatch(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if needle_lower.len() >= 32 && std::is_x86_feature_detected!("avx2") {
|
||||
return unsafe { verify_avx2(h, needle_lower) };
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
if needle_lower.len() >= 16 && std::arch::is_aarch64_feature_detected!("dotprod") {
|
||||
return unsafe { verify_neon_dotprod(h, needle_lower) };
|
||||
}
|
||||
}
|
||||
|
||||
verify_scalar(h, needle_lower)
|
||||
}
|
||||
|
||||
// ── Packed-pair search (AVX2) ───────────────────────────────────────────
|
||||
|
||||
/// AVX2 packed-pair kernel: scan 32 haystack positions per iteration,
|
||||
/// checking two rare bytes (case-insensitive) simultaneously.
|
||||
/// 4 cmpeq + 2 or + 1 and + 1 movemask per 32 bytes — same memory
|
||||
/// bandwidth as memchr2 but quadratic selectivity.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn search_packed_pair_avx2(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> bool {
|
||||
use core::arch::x86_64::*;
|
||||
|
||||
let n = needle_lower.len();
|
||||
let hlen = haystack.len();
|
||||
let ptr = haystack.as_ptr();
|
||||
let last_start = hlen - n; // last valid match-start position
|
||||
|
||||
let b1 = needle_lower[i1];
|
||||
let b1_alt = if b1.is_ascii_lowercase() {
|
||||
ascii_swap_case(b1)
|
||||
} else {
|
||||
b1
|
||||
};
|
||||
let b2 = needle_lower[i2];
|
||||
let b2_alt = if b2.is_ascii_lowercase() {
|
||||
ascii_swap_case(b2)
|
||||
} else {
|
||||
b2
|
||||
};
|
||||
|
||||
let v1_lo = _mm256_set1_epi8(b1 as i8);
|
||||
let v1_hi = _mm256_set1_epi8(b1_alt as i8);
|
||||
let v2_lo = _mm256_set1_epi8(b2 as i8);
|
||||
let v2_hi = _mm256_set1_epi8(b2_alt as i8);
|
||||
|
||||
// Main loop: process 32 candidate positions per iteration.
|
||||
// We load from ptr+offset+i1 and ptr+offset+i2, so we need
|
||||
// offset + max(i1,i2) + 31 < hlen.
|
||||
let max_idx = i1.max(i2);
|
||||
let max_offset = hlen.saturating_sub(max_idx + 32);
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset <= max_offset {
|
||||
let chunk1 = unsafe { _mm256_loadu_si256(ptr.add(offset + i1) as *const __m256i) };
|
||||
let chunk2 = unsafe { _mm256_loadu_si256(ptr.add(offset + i2) as *const __m256i) };
|
||||
|
||||
// Case-insensitive match: OR both case variants, then AND the two positions.
|
||||
let eq1 = _mm256_or_si256(
|
||||
_mm256_cmpeq_epi8(chunk1, v1_lo),
|
||||
_mm256_cmpeq_epi8(chunk1, v1_hi),
|
||||
);
|
||||
let eq2 = _mm256_or_si256(
|
||||
_mm256_cmpeq_epi8(chunk2, v2_lo),
|
||||
_mm256_cmpeq_epi8(chunk2, v2_hi),
|
||||
);
|
||||
|
||||
let mut mask = _mm256_movemask_epi8(_mm256_and_si256(eq1, eq2)) as u32;
|
||||
|
||||
while mask != 0 {
|
||||
let bit = mask.trailing_zeros() as usize;
|
||||
let candidate = offset + bit;
|
||||
if candidate > last_start {
|
||||
// Past the end — no more valid positions in this or future chunks.
|
||||
return false;
|
||||
}
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 32;
|
||||
}
|
||||
|
||||
// Tail: remaining positions that couldn't fill a full vector.
|
||||
// Use memchr2 on the rarest byte for these last few positions.
|
||||
if offset <= last_start {
|
||||
let rare_pos =
|
||||
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
|
||||
i1
|
||||
} else {
|
||||
i2
|
||||
};
|
||||
let rare_byte = needle_lower[rare_pos];
|
||||
let tail_start = offset + rare_pos;
|
||||
let tail_end = last_start + rare_pos + 1;
|
||||
if tail_start < tail_end {
|
||||
let tail_space = &haystack[tail_start..tail_end];
|
||||
if rare_byte.is_ascii_lowercase() {
|
||||
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Packed-pair case-insensitive substring search.
|
||||
///
|
||||
/// Selects the two rarest bytes from the needle (using the memchr byte
|
||||
/// frequency heuristic), then SIMD-scans for both at their known offsets
|
||||
/// simultaneously. Falls back to `search` for needles shorter than 2 bytes.
|
||||
pub fn search_packed_pair(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
if n < 2 {
|
||||
return search(haystack, needle_lower);
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (i1, i2) = select_rare_pair(needle_lower);
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if std::is_x86_feature_detected!("avx2") {
|
||||
// Need enough haystack for at least one vector load.
|
||||
let max_idx = i1.max(i2);
|
||||
if haystack.len() >= max_idx + 32 {
|
||||
return unsafe { search_packed_pair_avx2(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
// The NEON packed-pair scan checks 16 bytes/iteration with ~7 ops,
|
||||
// while memchr's optimized loop processes more bytes with fewer ops.
|
||||
// Packed-pair wins when the first byte is common (lots of false
|
||||
// positives for memchr2 that we avoid). But when the first byte is
|
||||
// rare (z, q, x, ...) memchr2 has no false positives and its raw
|
||||
// throughput dominates. Threshold 200 on the frequency table splits
|
||||
// common letters (s=243, e=253, f=227) from rare ones (z=152, q=139).
|
||||
let first_byte_rank = case_insensitive_rank(needle_lower[0]);
|
||||
let max_idx = i1.max(i2);
|
||||
if first_byte_rank >= 200 && haystack.len() >= max_idx + 16 {
|
||||
return unsafe { search_packed_pair_neon(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for short haystacks or non-SIMD platforms.
|
||||
search(haystack, needle_lower)
|
||||
}
|
||||
|
||||
// ── Original memchr2 first-byte search ──────────────────────────────────
|
||||
|
||||
/// Case-insensitive search using memchr2 on the first byte.
|
||||
pub fn search(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let search_space = &haystack[..=haystack.len() - n];
|
||||
let first = needle_lower[0];
|
||||
|
||||
if first.is_ascii_lowercase() {
|
||||
let alt = ascii_swap_case(first);
|
||||
for pos in memchr::memchr2_iter(first, alt, search_space) {
|
||||
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(first, search_space) {
|
||||
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_case_insensitive() {
|
||||
assert!(search_packed_pair(b"Hello World", b"hello"));
|
||||
assert!(search_packed_pair(b"Hello World", b"world"));
|
||||
assert!(search_packed_pair(b"NOMORE bugs", b"nomore"));
|
||||
assert!(!search_packed_pair(b"Hello World", b"xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases() {
|
||||
assert!(search_packed_pair(b"ab", b"ab"));
|
||||
assert!(search_packed_pair(b"AB", b"ab"));
|
||||
assert!(!search_packed_pair(b"a", b"ab"));
|
||||
assert!(search_packed_pair(b"anything", b""));
|
||||
assert!(!search_packed_pair(b"", b"x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_pair_matches_search() {
|
||||
let haystacks: &[&[u8]] = &[
|
||||
b"The quick brown fox jumps over the lazy dog",
|
||||
b"int mutex_lock(struct mutex *lock) { return 0; }",
|
||||
b"#define NOMORE_RETRIES 5\nif (nomore) return;",
|
||||
b"abcdefghijklmnopqrstuvwxyz",
|
||||
b"short",
|
||||
];
|
||||
let needles: &[&[u8]] = &[b"fox", b"mutex", b"nomore", b"xyz", b"the", b"short", b"qr"];
|
||||
for h in haystacks {
|
||||
for n in needles {
|
||||
let lower: Vec<u8> = n.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
assert_eq!(
|
||||
search_packed_pair(h, &lower),
|
||||
search(h, &lower),
|
||||
"mismatch for haystack={:?} needle={:?}",
|
||||
std::str::from_utf8(h),
|
||||
std::str::from_utf8(n),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_haystack_neon_path() {
|
||||
// Haystack > 16 bytes exercises NEON packed-pair search loop
|
||||
let haystack =
|
||||
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaTHIS_IS_A_LONG_NEEDLE_TESTbbbbbbbbbbbbbbbbbb";
|
||||
assert!(search_packed_pair(haystack, b"this_is_a_long_needle_test"));
|
||||
assert!(!search_packed_pair(
|
||||
haystack,
|
||||
b"this_is_a_long_needle_testz"
|
||||
));
|
||||
|
||||
// Needle >= 16 bytes exercises NEON dotprod verify
|
||||
let long_needle = b"struct mutex *lock";
|
||||
let haystack2 = b"int STRUCT MUTEX *LOCK(struct mutex *lock) { return 0; }";
|
||||
assert!(search_packed_pair(haystack2, long_needle));
|
||||
|
||||
// All uppercase haystack, lowercase needle
|
||||
let upper_hay = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
assert!(search_packed_pair(upper_hay, b"qrstuvwxyz0123456789a"));
|
||||
assert!(!search_packed_pair(upper_hay, b"qrstuvwxyz01234567899"));
|
||||
|
||||
// Needle at very end
|
||||
let end_hay = b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfind_me";
|
||||
assert!(search_packed_pair(end_hay, b"find_me"));
|
||||
|
||||
// Needle at very start
|
||||
assert!(search_packed_pair(end_hay, b"xx"));
|
||||
|
||||
// 1KB haystack with needle near the end
|
||||
let mut big = vec![b'z'; 1024];
|
||||
big[1000..1010].copy_from_slice(b"hElLo_WoRl");
|
||||
assert!(search_packed_pair(&big, b"hello_wo"));
|
||||
assert!(!search_packed_pair(&big, b"hello_world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rare_pair_selection() {
|
||||
// For "nomore": n=246, o=244, m=233, o=244, r=245, e=253
|
||||
// Rarest positions should include 'm' (pos 2, rank 233)
|
||||
let (i1, i2) = select_rare_pair(b"nomore");
|
||||
let ranks: Vec<u8> = b"nomore"
|
||||
.iter()
|
||||
.map(|&b| case_insensitive_rank(b))
|
||||
.collect();
|
||||
let r1 = ranks[i1];
|
||||
let r2 = ranks[i2];
|
||||
// Both selected ranks should be <= all other ranks
|
||||
for (i, &r) in ranks.iter().enumerate() {
|
||||
if i != i1 && i != i2 {
|
||||
assert!(r1 <= r || r2 <= r, "pair ({i1},{i2}) not optimal");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,32 @@ use smallvec::SmallVec;
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
|
||||
/// Case-insensitive ASCII substring search without allocation.
|
||||
/// `needle` must already be lowercase.
|
||||
#[inline]
|
||||
fn contains_ascii_ci(haystack: &str, needle: &str) -> bool {
|
||||
let h = haystack.as_bytes();
|
||||
let n = needle.as_bytes();
|
||||
if n.len() > h.len() {
|
||||
return false;
|
||||
}
|
||||
if n.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let first = n[0];
|
||||
for i in 0..=(h.len() - n.len()) {
|
||||
if h[i].to_ascii_lowercase() == first
|
||||
&& h[i..i + n.len()]
|
||||
.iter()
|
||||
.zip(n)
|
||||
.all(|(a, b)| a.to_ascii_lowercase() == *b)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Minimum item count before switching to parallel iteration with rayon.
|
||||
/// Below this threshold, the overhead of thread pool dispatch outweighs the benefit.
|
||||
const PAR_THRESHOLD: usize = 10_000;
|
||||
@@ -22,9 +48,6 @@ pub trait Constrainable {
|
||||
/// The file's relative path (e.g. "src/main.rs")
|
||||
fn relative_path(&self) -> &str;
|
||||
|
||||
/// The file's lowercased relative path for case-insensitive matching
|
||||
fn relative_path_lower(&self) -> &str;
|
||||
|
||||
/// The file name component (e.g. "main.rs")
|
||||
fn file_name(&self) -> &str;
|
||||
|
||||
@@ -32,6 +55,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 +89,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 +148,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),
|
||||
@@ -127,7 +175,7 @@ fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
}
|
||||
|
||||
// only works with negation
|
||||
Constraint::Text(text) => item.relative_path_lower().contains(text),
|
||||
Constraint::Text(text) => contains_ascii_ci(item.relative_path(), text),
|
||||
|
||||
// Parts and Exclude are handled at a higher level
|
||||
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
|
||||
@@ -347,8 +395,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ pub enum Error {
|
||||
ThreadPanic,
|
||||
#[error("Invalid path {0}")]
|
||||
InvalidPath(std::path::PathBuf),
|
||||
#[error(
|
||||
"Can not start fff at the file system root {0} — pass a project or at least home directory instead"
|
||||
)]
|
||||
FilesystemRoot(std::path::PathBuf),
|
||||
#[error("File picker not initialized")]
|
||||
FilePickerMissing,
|
||||
#[error("Failed to acquire lock for frecency")]
|
||||
@@ -21,6 +25,8 @@ pub enum Error {
|
||||
EnvOpen(#[source] heed::Error),
|
||||
#[error("Failed to create frecency database: {0}")]
|
||||
DbCreate(#[source] heed::Error),
|
||||
#[error("Failed to open frecency database: {0}")]
|
||||
DbOpen(#[source] heed::Error),
|
||||
#[error("Failed to clear stale readers for frecency database: {0}")]
|
||||
DbClearStaleReaders(#[source] heed::Error),
|
||||
|
||||
|
||||
+1193
-408
File diff suppressed because it is too large
Load Diff
+290
-23
@@ -1,11 +1,15 @@
|
||||
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::git::is_modified_status;
|
||||
use crate::shared::SharedFrecency;
|
||||
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 +17,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 +35,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 +58,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);
|
||||
}
|
||||
@@ -53,11 +73,28 @@ impl FrecencyTracker {
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
|
||||
// we will open the default unnamed database
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, None)
|
||||
.map_err(Error::DbCreate)?;
|
||||
// Try read-only open first — avoids blocking on the LMDB write lock
|
||||
// when another process (Neovim, another fff-mcp) already has it.
|
||||
// Only fall back to create_database (which needs a write txn) if the
|
||||
// database doesn't exist yet.
|
||||
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let maybe_db: Option<Database<Bytes, SerdeBincode<VecDeque<u64>>>> =
|
||||
env.open_database(&rtxn, None).map_err(Error::DbOpen)?;
|
||||
|
||||
drop(rtxn);
|
||||
|
||||
let db = match maybe_db {
|
||||
Some(db) => db,
|
||||
None => {
|
||||
// First time: create the database (requires write lock).
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, None)
|
||||
.map_err(Error::DbCreate)?;
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
db
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FrecencyTracker {
|
||||
db,
|
||||
@@ -65,7 +102,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 +318,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 +326,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 +362,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 +373,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 +395,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 +413,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 +457,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 +535,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")
|
||||
}
|
||||
|
||||
+1419
-344
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const NON_GIT_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
".venv",
|
||||
// Rust (these are glob-only patterns for non_git_repo_overrides,
|
||||
// is_non_code_directory matches the "target" component separately)
|
||||
"target/debug",
|
||||
"target/release",
|
||||
"target/rust-analyzer",
|
||||
"target/criterion",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] =
|
||||
&["Library/Application Support", "Library/Caches"];
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
|
||||
"bin/Debug",
|
||||
"bin/Release",
|
||||
"Program Files",
|
||||
"Program Files (x86)",
|
||||
"AppData/Local",
|
||||
"AppData/Roaming",
|
||||
];
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[];
|
||||
|
||||
pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option<ignore::overrides::Override> {
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
|
||||
let mut builder = OverrideBuilder::new(base_path);
|
||||
for dir in NON_GIT_IGNORED_DIRS.iter().chain(PLATFORM_IGNORED_DIRS) {
|
||||
let pattern = format!("!**/{dir}/");
|
||||
if let Err(e) = builder.add(&pattern) {
|
||||
tracing::warn!("failed to add ignore pattern {pattern}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
builder.build().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn is_non_code_directory(path: &Path) -> bool {
|
||||
let path_str = path.as_os_str().to_str().unwrap_or("");
|
||||
NON_GIT_IGNORED_DIRS
|
||||
.iter()
|
||||
.chain(PLATFORM_IGNORED_DIRS)
|
||||
.any(|&dir| {
|
||||
#[cfg(target_os = "windows")]
|
||||
let dir = dir.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
#[cfg(target_os = "windows")]
|
||||
return path_str.contains(dir.as_str());
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
path_str.contains(dir)
|
||||
})
|
||||
}
|
||||
+141
-29
@@ -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
|
||||
//! newtype wrappers around `Arc<RwLock<Option<T>>>` for thread-safe shared
|
||||
//! access. They provide `read()` / `write()` methods with built-in error
|
||||
//! conversion and convenience helpers like `wait_for_scan()`.
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ```
|
||||
//! use fff_search::file_picker::FilePicker;
|
||||
//! use fff_search::frecency::FrecencyTracker;
|
||||
//! use fff_search::query_tracker::QueryTracker;
|
||||
//! use fff_search::{
|
||||
//! FFFMode, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
|
||||
//! SharedFrecency, SharedPicker, SharedQueryTracker,
|
||||
//! };
|
||||
//!
|
||||
//! let shared_picker = SharedPicker::default();
|
||||
//! let shared_frecency = SharedFrecency::default();
|
||||
//! let shared_query_tracker = SharedQueryTracker::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.init(frecency)?;
|
||||
//!
|
||||
//! let query_tracker = QueryTracker::new(tmp.join("queries"), false)?;
|
||||
//! shared_query_tracker.init(query_tracker)?;
|
||||
//!
|
||||
//! // 2. Init the file picker (spawns background scan + watcher)
|
||||
//! FilePicker::new_with_shared_state(
|
||||
//! shared_picker.clone(),
|
||||
//! shared_frecency.clone(),
|
||||
//! FilePickerOptions {
|
||||
//! base_path: ".".into(),
|
||||
//! mode: FFFMode::Ai,
|
||||
//! ..Default::default()
|
||||
//! },
|
||||
//! )?;
|
||||
//!
|
||||
//! // 3. Wait for scan
|
||||
//! shared_picker.wait_for_scan(std::time::Duration::from_secs(10));
|
||||
//!
|
||||
//! // 4. Search: lock the picker and query tracker
|
||||
//! let picker_guard = shared_picker.read()?;
|
||||
//! let picker = picker_guard.as_ref().unwrap();
|
||||
//! let qt_guard = shared_query_tracker.read()?;
|
||||
//!
|
||||
//! // 5. Parse the query and perform fuzzy search
|
||||
//! let parser = QueryParser::default();
|
||||
//! let query = parser.parse("lib.rs");
|
||||
//!
|
||||
//! let results = FilePicker::fuzzy_search(
|
||||
//! picker.get_files(),
|
||||
//! &query,
|
||||
//! qt_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().as_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 bigram_filter;
|
||||
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;
|
||||
// this is pub only for benchmarks
|
||||
pub mod case_insensitive_memmem;
|
||||
|
||||
/// 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};
|
||||
|
||||
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
|
||||
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
|
||||
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
|
||||
mod ignore;
|
||||
/// Thread-safe shared handles for [`FilePicker`], [`FrecencyTracker`],
|
||||
/// and [`QueryTracker`].
|
||||
pub mod shared;
|
||||
|
||||
pub use bigram_filter::*;
|
||||
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 shared::*;
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Shared logging utilities for FFF crates.
|
||||
//!
|
||||
//! Provides file-based tracing initialization and crash handlers (panic hook
|
||||
//! + SIGSEGV signal handler) that write diagnostics to both stderr and the
|
||||
//! configured log file.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
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 CRASH_HANDLERS_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
|
||||
/// The log file path set by `init_tracing`. Crash handlers append to this file.
|
||||
static LOG_FILE_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
fn write_crash_report(header: &str, body: &str) {
|
||||
let msg = format!(
|
||||
"\n=== CRASH {} ===\n{}\n=== CRASH END {} ===\n",
|
||||
header, body, header
|
||||
);
|
||||
|
||||
let _ = std::io::Write::write_all(&mut std::io::stderr(), msg.as_bytes());
|
||||
|
||||
if let Some(path) = LOG_FILE_PATH.get() {
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.and_then(|mut f| std::io::Write::write_all(&mut f, msg.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn sigsegv_handler(sig: libc::c_int) {
|
||||
let bt = std::backtrace::Backtrace::force_capture();
|
||||
write_crash_report("SIGSEGV", &format!("signal {}\n{}", sig, bt));
|
||||
|
||||
unsafe {
|
||||
libc::signal(sig, libc::SIG_DFL);
|
||||
libc::raise(sig);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install both the panic hook and the SIGSEGV signal handler.
|
||||
pub fn install_panic_hook() {
|
||||
CRASH_HANDLERS_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = panic_info
|
||||
.location()
|
||||
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||
.unwrap_or_else(|| "unknown location".to_string());
|
||||
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF"
|
||||
);
|
||||
|
||||
write_crash_report(
|
||||
"RUST PANIC",
|
||||
&format!("Message: {}\nLocation: {}", message, location),
|
||||
);
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
|
||||
unsafe {
|
||||
libc::signal(
|
||||
libc::SIGSEGV,
|
||||
sigsegv_handler as *const () as libc::sighandler_t,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Parse a log level string into a `tracing::Level`.
|
||||
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.
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let _ = LOG_FILE_PATH.set(log_path.to_path_buf());
|
||||
install_panic_hook();
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true) // truncates a file on restart (instead of appending)
|
||||
.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_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);
|
||||
@@ -243,7 +246,6 @@ impl QueryTracker {
|
||||
min_combo_count: u32,
|
||||
) -> Result<Option<QueryMatchEntry>, Error> {
|
||||
let query_key = Self::create_query_key(project_path, query)?;
|
||||
tracing::debug!(?query_key, "HASH");
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let last_match = self
|
||||
|
||||
+311
-162
@@ -34,24 +34,6 @@ impl<'a> FileItems<'a> {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get(&self, index: usize) -> Option<&'a FileItem> {
|
||||
match self {
|
||||
FileItems::All(s) => s.get(index),
|
||||
FileItems::Filtered(v) => v.get(index).copied(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the haystack of relative paths (original casing) for fuzzy matching.
|
||||
/// neo_frizbee lowercases internally for comparison but preserves original casing
|
||||
/// for capitalization_bonus and matching_case_bonus scoring.
|
||||
fn relative_paths(&self) -> Vec<&'a str> {
|
||||
match self {
|
||||
FileItems::All(s) => s.iter().map(|f| f.relative_path.as_str()).collect(),
|
||||
FileItems::Filtered(v) => v.iter().map(|f| f.relative_path.as_str()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index into the file list. Panics if out of bounds (like slice indexing).
|
||||
#[inline]
|
||||
fn index(&self, index: usize) -> &'a FileItem {
|
||||
@@ -66,17 +48,17 @@ impl<'a> FileItems<'a> {
|
||||
/// Single part: use optimized batch matching.
|
||||
/// Multiple parts: each part must match, scores are summed (Nucleo-style).
|
||||
/// Parts with less than 2 characters are skipped.
|
||||
///
|
||||
/// Files are passed directly to frizbee via the `Matchable` trait —
|
||||
/// deleted files return `None` from `match_str()` and are skipped
|
||||
/// without any intermediate allocation.
|
||||
#[inline]
|
||||
fn match_fuzzy_parts(
|
||||
fuzzy_parts: &[&str],
|
||||
working_files: &FileItems<'_>,
|
||||
options: &neo_frizbee::Config,
|
||||
max_threads: usize,
|
||||
) -> Vec<neo_frizbee::Match> {
|
||||
if fuzzy_parts.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let haystack: Vec<&str> = working_files.relative_paths();
|
||||
|
||||
// Filter out parts that are too short (< 2 chars)
|
||||
let valid_parts: Vec<&str> = fuzzy_parts
|
||||
.iter()
|
||||
@@ -89,14 +71,21 @@ fn match_fuzzy_parts(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let first_part_matches = match working_files {
|
||||
FileItems::All(files) => {
|
||||
neo_frizbee::match_list_parallel(valid_parts[0], files, options, max_threads)
|
||||
}
|
||||
FileItems::Filtered(files) => {
|
||||
neo_frizbee::match_list_parallel(valid_parts[0], files, options, max_threads)
|
||||
}
|
||||
};
|
||||
|
||||
if valid_parts.len() == 1 {
|
||||
let matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
|
||||
return matches;
|
||||
return first_part_matches;
|
||||
}
|
||||
|
||||
// Multiple parts - match first part, then filter by remaining parts
|
||||
// TODO figure out if we can move this logic to my frizbee fork at least
|
||||
let mut matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
|
||||
let mut matches = first_part_matches;
|
||||
for part in valid_parts[1..].iter() {
|
||||
let mut part_options = *options;
|
||||
part_options.max_typos = options.max_typos.map(|t| t.min(part.len() as u16));
|
||||
@@ -104,8 +93,9 @@ fn match_fuzzy_parts(
|
||||
matches = matches
|
||||
.into_iter()
|
||||
.filter_map(|mut m| {
|
||||
let path = haystack.get(m.index as usize)?;
|
||||
let part_matches = neo_frizbee::match_list(part, &[*path], &part_options);
|
||||
let file = working_files.index(m.index as usize);
|
||||
let path = file.relative_path();
|
||||
let part_matches = neo_frizbee::match_list(part, &[path], &part_options);
|
||||
let part_match = part_matches.first()?;
|
||||
|
||||
// Sum scores
|
||||
@@ -131,40 +121,27 @@ 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 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");
|
||||
if query_trimmed.len() < 2 {
|
||||
return score_filtered_by_frecency(&working_files, context);
|
||||
}
|
||||
&single_part_storage
|
||||
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(),
|
||||
_ => {
|
||||
return score_filtered_by_frecency(&working_files, context);
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
debug_assert!(!fuzzy_parts.is_empty());
|
||||
|
||||
let has_uppercase = fuzzy_parts
|
||||
.iter()
|
||||
@@ -172,7 +149,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 {
|
||||
@@ -182,104 +158,130 @@ pub fn match_and_score_files<'a>(
|
||||
},
|
||||
};
|
||||
|
||||
let path_matches = match_fuzzy_parts(fuzzy_parts, &working_files, &options);
|
||||
let primary_text = fuzzy_parts[0]; // Use first part for filename matching
|
||||
let haystack_of_filenames: Vec<&str> = path_matches
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
working_files
|
||||
.get(m.index as usize)
|
||||
.map(|f| f.file_name.as_str())
|
||||
})
|
||||
.collect();
|
||||
let path_matches =
|
||||
match_fuzzy_parts(fuzzy_parts, &working_files, &options, context.max_threads);
|
||||
|
||||
// if there is a / in the query we don't even match filenames
|
||||
let filename_matches = if query_contains_path_separator {
|
||||
let main_needle = fuzzy_parts[0].as_bytes(); // safe
|
||||
let main_needle_len = main_needle.len() as u16;
|
||||
|
||||
// Filename match detection: two tiers, cursor-based (no intermediate bitset/Vec<bool>).
|
||||
// 1) Collect filenames only where match_end_col didn't land in the filename region.
|
||||
// 2) Batch SIMD on that subset, remap indices, sort for cursor walk in the scoring loop.
|
||||
let mut fallback_indices: Vec<u32> = Vec::new();
|
||||
let filename_fallback_matches = if query_contains_path_separator || path_matches.len() > 15_000
|
||||
{
|
||||
vec![]
|
||||
} else {
|
||||
// Use parallel matching only if we have enough filenames to justify overhead
|
||||
// Sequential matching is faster for small result sets (< 1000 matches)
|
||||
let mut list = if haystack_of_filenames.len() > 1000 {
|
||||
neo_frizbee::match_list_parallel(
|
||||
primary_text,
|
||||
&haystack_of_filenames,
|
||||
&options,
|
||||
context.max_threads,
|
||||
)
|
||||
} else {
|
||||
neo_frizbee::match_list(primary_text, &haystack_of_filenames, &options)
|
||||
};
|
||||
let mut fallback_filenames: Vec<&str> = Vec::new();
|
||||
|
||||
// Sequential sort is faster for small lists
|
||||
if list.len() > 1000 {
|
||||
list.par_sort_unstable_by_key(|m| m.index);
|
||||
} else {
|
||||
sort_by_key_with_buffer(&mut list, |m| m.index);
|
||||
for (i, path_match) in path_matches.iter().enumerate() {
|
||||
let file = working_files.index(path_match.index as usize);
|
||||
let filename_start = file.filename_offset_in_relative() as u16;
|
||||
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
|
||||
|
||||
if match_start_approx < filename_start {
|
||||
fallback_indices.push(i as u32);
|
||||
fallback_filenames.push(file.file_name());
|
||||
}
|
||||
}
|
||||
|
||||
list
|
||||
if fallback_filenames.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
let mut matches = neo_frizbee::match_list_parallel(
|
||||
fuzzy_parts[0],
|
||||
&fallback_filenames,
|
||||
&options,
|
||||
if path_matches.len() > 10_000 {
|
||||
context.max_threads
|
||||
} else {
|
||||
1
|
||||
},
|
||||
);
|
||||
|
||||
sort_by_key_with_buffer(&mut matches, |m| fallback_indices[m.index as usize]);
|
||||
matches
|
||||
}
|
||||
};
|
||||
|
||||
let mut next_filename_match_index = 0;
|
||||
let mut next_filename_match_cursor = 0;
|
||||
let results: Vec<_> = path_matches
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, path_match)| {
|
||||
.map(|(match_idx, path_match)| {
|
||||
let file_idx = path_match.index as usize;
|
||||
let file = working_files.index(file_idx);
|
||||
|
||||
let mut base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
|
||||
let distance_penalty =
|
||||
calculate_distance_penalty(context.current_file, &file.relative_path);
|
||||
let base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score()) / 100;
|
||||
|
||||
let filename_match = filename_matches
|
||||
.get(next_filename_match_index)
|
||||
.and_then(|m| {
|
||||
if m.index == index as u32 {
|
||||
next_filename_match_index += 1;
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
// 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());
|
||||
|
||||
let filename_start = file.filename_offset_in_relative() as u16;
|
||||
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
|
||||
|
||||
let end_col_filename_match = match_start_approx >= filename_start;
|
||||
let simd_filename_match = if !end_col_filename_match {
|
||||
filename_fallback_matches
|
||||
.get(next_filename_match_cursor)
|
||||
.and_then(|m| {
|
||||
if fallback_indices[m.index as usize] == match_idx as u32 {
|
||||
next_filename_match_cursor += 1;
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let is_filename_match = end_col_filename_match || simd_filename_match.is_some();
|
||||
let is_exact_filename = simd_filename_match.is_some_and(|m| m.exact)
|
||||
|| (end_col_filename_match
|
||||
&& main_needle_len as usize == file.file_name().len()
|
||||
&& main_needle.eq_ignore_ascii_case(file.file_name().as_bytes()));
|
||||
|
||||
let mut has_special_filename_bonus = false;
|
||||
let filename_bonus = match filename_match {
|
||||
Some(filename_match) if filename_match.exact => {
|
||||
filename_match.score as i32 / 5 * 2 // 40% bonus for exact filename match
|
||||
let filename_bonus = if is_exact_filename {
|
||||
base_score / 5 * 2 // 40% bonus for exact filename match
|
||||
} else if is_filename_match {
|
||||
// 16% bonus for fuzzy filename match that landed in the filename region.
|
||||
// For fallback matches (where the path match landed in a directory segment),
|
||||
// scale the bonus by the quality of the filename match — a contiguous match
|
||||
// like "rename" in "rename.ts" gets the full bonus, while a scattered
|
||||
// subsequence like r-e-n-a-m-e in "generateSessionName.ts" gets much less.
|
||||
let max_bonus = (base_score / 6).min(30);
|
||||
if let Some(fm) = simd_filename_match {
|
||||
let max_possible = main_needle_len as i32 * 16;
|
||||
let quality = (fm.score as i32).min(max_possible);
|
||||
max_bonus * quality / max_possible
|
||||
} else {
|
||||
max_bonus
|
||||
}
|
||||
// 16% bonus for fuzzy filename match but only if the score of matched path is
|
||||
// equal or greater than the score of matched filename, thus we are not allowing
|
||||
// typoed filename to score higher than the path match
|
||||
Some(filename_match)
|
||||
if filename_match.score >= path_match.score
|
||||
&& !query_contains_path_separator =>
|
||||
{
|
||||
base_score = filename_match.score as i32;
|
||||
|
||||
(base_score / 6)
|
||||
// for large queries around ~300 score the bonus is too big
|
||||
// it might lead to situations when much more fitting path with a larger
|
||||
// base score getting filtered out by combination of score + filename bonus
|
||||
// so we cap it at 10% of the roughly largest score you can get
|
||||
.min(30)
|
||||
}
|
||||
// 5% bonus for special file but not as much as file name to avoid sitatuions
|
||||
} else if !is_filename_match && is_special_entry_point_file(file.file_name()) {
|
||||
// 5% bonus for special file but not as much as file name to avoid situations
|
||||
// when you have /user_service/server.rs and /user_service/server/mod.rs
|
||||
None if is_special_entry_point_file(&file.file_name) => {
|
||||
has_special_filename_bonus = true;
|
||||
base_score * 5 / 100
|
||||
}
|
||||
_ => 0,
|
||||
has_special_filename_bonus = true;
|
||||
base_score * 5 / 100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let current_file_penalty = calculate_current_file_penalty(file, base_score, context);
|
||||
|
||||
let combo_match_boost = {
|
||||
let last_same_query_match = context
|
||||
.last_same_query_match
|
||||
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
|
||||
.as_ref()
|
||||
.filter(|m| m.file_path.as_os_str() == file.as_path().as_os_str());
|
||||
|
||||
match last_same_query_match {
|
||||
// if we request a combo match without a boost we have to render it anyway
|
||||
@@ -296,6 +298,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,13 +315,18 @@ 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 => "fuzzy_path",
|
||||
exact_match: is_exact_filename || path_match.exact,
|
||||
match_type: if is_exact_filename {
|
||||
"exact_filename"
|
||||
} else if is_filename_match {
|
||||
"fuzzy_filename"
|
||||
} else if path_match.exact {
|
||||
"exact_path"
|
||||
} else {
|
||||
"fuzzy_path"
|
||||
},
|
||||
};
|
||||
|
||||
@@ -363,9 +371,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 +393,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",
|
||||
};
|
||||
@@ -384,8 +402,16 @@ pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
};
|
||||
|
||||
let results: Vec<_> = match files {
|
||||
FileItems::All(s) => s.par_iter().map(&score_file).collect(),
|
||||
FileItems::Filtered(v) => v.iter().map(|&file| score_file(file)).collect(),
|
||||
FileItems::All(s) => s
|
||||
.par_iter()
|
||||
.filter(|f| !f.is_deleted())
|
||||
.map(&score_file)
|
||||
.collect(),
|
||||
FileItems::Filtered(v) => v
|
||||
.iter()
|
||||
.filter(|f| !f.is_deleted())
|
||||
.map(|&file| score_file(file))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
sort_and_paginate(results, context)
|
||||
@@ -400,7 +426,7 @@ fn calculate_current_file_penalty(
|
||||
let mut penalty = 0i32;
|
||||
|
||||
if let Some(current) = context.current_file
|
||||
&& file.relative_path.as_str() == current
|
||||
&& file.relative_path() == current
|
||||
{
|
||||
penalty -= match file.git_status {
|
||||
Some(status) if is_modified_status(status) => base_score / 2,
|
||||
@@ -482,14 +508,14 @@ fn sort_and_paginate<'a>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::PaginationArgs;
|
||||
use std::path::PathBuf;
|
||||
use fff_query_parser::QueryParser;
|
||||
|
||||
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
|
||||
let file_name = path.split('/').last().unwrap_or(path).to_string();
|
||||
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
|
||||
let file = FileItem::new_raw(
|
||||
PathBuf::from(path),
|
||||
path.to_string(),
|
||||
file_name,
|
||||
0,
|
||||
filename_start,
|
||||
0,
|
||||
modified,
|
||||
None,
|
||||
@@ -503,6 +529,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 +559,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,
|
||||
@@ -560,15 +589,15 @@ mod tests {
|
||||
assert_eq!(scores[2].total, 200, "Third should be third highest");
|
||||
|
||||
// Verify the files match
|
||||
assert_eq!(items[0].relative_path, "file4.rs");
|
||||
assert_eq!(items[1].relative_path, "file6.rs");
|
||||
assert_eq!(items[2].relative_path, "file2.rs");
|
||||
assert_eq!(items[0].relative_path(), "file4.rs");
|
||||
assert_eq!(items[1].relative_path(), "file6.rs");
|
||||
assert_eq!(items[2].relative_path(), "file2.rs");
|
||||
}
|
||||
|
||||
#[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 +610,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 +648,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 +659,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,
|
||||
@@ -652,9 +685,127 @@ mod tests {
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(scores[1].total, 100);
|
||||
assert_eq!(scores[2].total, 50);
|
||||
assert_eq!(items[0].relative_path, "file2.rs");
|
||||
assert_eq!(items[1].relative_path, "file1.rs");
|
||||
assert_eq!(items[2].relative_path, "file3.rs");
|
||||
assert_eq!(items[0].relative_path(), "file2.rs");
|
||||
assert_eq!(items[1].relative_path(), "file1.rs");
|
||||
assert_eq!(items[2].relative_path(), "file3.rs");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod filename_bonus_tests {
|
||||
use super::*;
|
||||
use crate::types::PaginationArgs;
|
||||
use fff_query_parser::QueryParser;
|
||||
|
||||
fn make_file(path: &str) -> FileItem {
|
||||
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
|
||||
FileItem::new_raw(path.to_string(), 0, filename_start, 0, 0, None, false)
|
||||
}
|
||||
|
||||
fn search(files: &[FileItem], query: &str) -> Vec<(String, Score)> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let ctx = ScoringContext {
|
||||
query: &parsed,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
};
|
||||
let (items, scores, _) = match_and_score_files(files, &ctx);
|
||||
items
|
||||
.iter()
|
||||
.zip(scores.iter())
|
||||
.map(|(f, s)| (f.relative_path().to_string(), s.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_match_ranks_above_path_only_match() {
|
||||
let files = vec![
|
||||
make_file("src/username/handler.rs"),
|
||||
make_file("src/username/username.rs"),
|
||||
];
|
||||
|
||||
let results = search(&files, "usrnmea");
|
||||
|
||||
assert!(
|
||||
results.len() >= 2,
|
||||
"both files should match, got {}",
|
||||
results.len()
|
||||
);
|
||||
assert_eq!(
|
||||
results[0].0, "src/username/username.rs",
|
||||
"filename match should rank first"
|
||||
);
|
||||
assert!(
|
||||
results[0].1.filename_bonus > 0,
|
||||
"username.rs should have filename bonus"
|
||||
);
|
||||
assert_eq!(
|
||||
results[1].1.filename_bonus, 0,
|
||||
"handler.rs should have no filename bonus"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_filename_beats_fuzzy_filename() {
|
||||
// "username.rs" exactly matches "username.rs" → exact filename
|
||||
// "username.rs" is a fuzzy match of "user_name_handler.rs" → fuzzy bonus only
|
||||
let files = vec![
|
||||
make_file("src/user_name_handler.rs"),
|
||||
make_file("src/username.rs"),
|
||||
];
|
||||
|
||||
let results = search(&files, "username.rs");
|
||||
|
||||
assert!(results.len() >= 2);
|
||||
assert_eq!(
|
||||
results[0].0, "src/username.rs",
|
||||
"exact filename should rank first"
|
||||
);
|
||||
assert_eq!(results[0].1.match_type, "exact_filename");
|
||||
assert!(results[0].1.filename_bonus > results[1].1.filename_bonus);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_length_filename_no_false_exact() {
|
||||
// "item.rs" exactly matches "item.rs" → exact_filename
|
||||
// "item.rs" should NOT get exact_filename on "file.rs" even though stem lengths match
|
||||
let files = vec![
|
||||
make_file("src/item_sync/file.rs"),
|
||||
make_file("src/models/item.rs"),
|
||||
];
|
||||
|
||||
let results = search(&files, "item.rs");
|
||||
|
||||
assert!(results.len() >= 2);
|
||||
assert_eq!(results[0].0, "src/models/item.rs");
|
||||
assert_eq!(results[0].1.match_type, "exact_filename");
|
||||
assert_ne!(
|
||||
results[1].1.match_type, "exact_filename",
|
||||
"file.rs should not get exact_filename"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_separator_disables_filename_bonus() {
|
||||
let files = vec![make_file("src/controllers/user.rs")];
|
||||
|
||||
let results = search(&files, "src/user");
|
||||
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(
|
||||
results[0].1.filename_bonus, 0,
|
||||
"path-like query should not get filename bonus"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,7 +817,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 +848,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()
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
|
||||
/// Thread-safe shared handle to the [`FilePicker`] instance.
|
||||
///
|
||||
/// Uses `parking_lot::RwLock` which is reader-fair — new readers are not
|
||||
/// blocked when a writer is waiting, preventing search query stalls during
|
||||
/// background bigram builds or watcher writes.
|
||||
///
|
||||
/// `Clone` gives a new handle to the same picker (Arc clone).
|
||||
/// `Default` creates an empty handle suitable for `Lazy::new(SharedPicker::default)`.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SharedPicker(pub(crate) Arc<parking_lot::RwLock<Option<FilePicker>>>);
|
||||
|
||||
impl std::fmt::Debug for SharedPicker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedPicker").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedPicker {
|
||||
pub fn read(&self) -> Result<parking_lot::RwLockReadGuard<'_, Option<FilePicker>>, Error> {
|
||||
Ok(self.0.read())
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, Option<FilePicker>>, Error> {
|
||||
Ok(self.0.write())
|
||||
}
|
||||
|
||||
/// Block until the background filesystem scan finishes.
|
||||
/// Returns `true` if scan completed, `false` on timeout.
|
||||
pub fn wait_for_scan(&self, timeout: Duration) -> bool {
|
||||
let signal = {
|
||||
let guard = self.0.read();
|
||||
match &*guard {
|
||||
Some(picker) => picker.scan_signal(),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Block until the background file watcher is ready.
|
||||
/// Returns `true` if watcher ready, `false` on timeout.
|
||||
pub fn wait_for_watcher(&self, timeout: Duration) -> bool {
|
||||
let signal = {
|
||||
let guard = self.0.read();
|
||||
match &*guard {
|
||||
Some(picker) => picker.watcher_signal(),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while !signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Refresh git statuses for all indexed files.
|
||||
pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result<usize, Error> {
|
||||
use git2::StatusOptions;
|
||||
use tracing::debug;
|
||||
|
||||
let git_status = {
|
||||
let guard = self.read()?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Refreshing git statuses for picker: {:?}",
|
||||
picker.git_root()
|
||||
);
|
||||
|
||||
GitStatusCache::read_git_status(
|
||||
picker.git_root(),
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true),
|
||||
)
|
||||
};
|
||||
|
||||
let mut guard = self.write()?;
|
||||
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
|
||||
|
||||
let statuses_count = if let Some(git_status) = git_status {
|
||||
let count = git_status.statuses_len();
|
||||
picker.update_git_statuses(git_status, shared_frecency)?;
|
||||
count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(statuses_count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SharedFrecency(pub(crate) Arc<RwLock<Option<FrecencyTracker>>>);
|
||||
|
||||
impl std::fmt::Debug for SharedFrecency {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedFrecency").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedFrecency {
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
self.0.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
self.0.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
/// Initialize the frecency tracker, replacing any existing one.
|
||||
pub fn init(&self, tracker: FrecencyTracker) -> Result<(), Error> {
|
||||
let mut guard = self.write()?;
|
||||
*guard = Some(tracker);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a background GC thread for this frecency tracker.
|
||||
pub fn spawn_gc(
|
||||
&self,
|
||||
db_path: String,
|
||||
use_unsafe_no_lock: bool,
|
||||
) -> crate::Result<std::thread::JoinHandle<()>> {
|
||||
FrecencyTracker::spawn_gc(self.clone(), db_path, use_unsafe_no_lock)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`QueryTracker`] instance.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SharedQueryTracker(pub(crate) Arc<RwLock<Option<QueryTracker>>>);
|
||||
|
||||
impl std::fmt::Debug for SharedQueryTracker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedQueryTracker").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedQueryTracker {
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<QueryTracker>>, Error> {
|
||||
self.0.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<QueryTracker>>, Error> {
|
||||
self.0.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
/// Initialize the query tracker, replacing any existing one.
|
||||
pub fn init(&self, tracker: QueryTracker) -> Result<(), Error> {
|
||||
let mut guard = self.write()?;
|
||||
*guard = Some(tracker);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_key_with_buffer() {
|
||||
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
|
||||
sort_by_key_with_buffer(&mut data, |item| item.0);
|
||||
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
|
||||
let mut data = vec![(1, 50), (2, 20), (3, 80), (4, 10), (5, 90)];
|
||||
sort_by_key_with_buffer(&mut data, |a| a.1);
|
||||
assert_eq!(data, vec![(4, 10), (2, 20), (1, 50), (3, 80), (5, 90)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+406
-90
@@ -1,145 +1,368 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use memmap2::Mmap;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
use neo_frizbee::Matchable;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// The `Buffer` variant is also used on Unix for temporary (uncached) reads
|
||||
/// where the mmap/munmap syscall overhead exceeds the cost of a heap copy.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)] // variants are conditionally used per platform
|
||||
pub enum FileContent {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
Mmap(memmap2::Mmap),
|
||||
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,
|
||||
FileContent::Buffer(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileItemFlags;
|
||||
|
||||
impl FileItemFlags {
|
||||
pub const BINARY: u8 = 1 << 0;
|
||||
/// Tombstone — file was deleted but index slot is preserved so
|
||||
/// bigram indices for other files stay valid.
|
||||
pub const DELETED: u8 = 1 << 1;
|
||||
}
|
||||
|
||||
/// A single indexed file with metadata, frecency scores, and lazy content cache.
|
||||
/// Occupies ~100 bytes + file path per file
|
||||
///
|
||||
/// 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`.
|
||||
#[derive(Debug)]
|
||||
pub struct FileItem {
|
||||
pub path: PathBuf,
|
||||
pub relative_path: String,
|
||||
pub relative_path_lower: String,
|
||||
pub file_name: String,
|
||||
pub file_name_lower: String,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
/// Modification time in UNIX timestamp
|
||||
pub modified: u64,
|
||||
pub access_frecency_score: i64,
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
/// Frecency access score
|
||||
pub access_frecency_score: i16,
|
||||
/// Frecency modification score
|
||||
pub modification_frecency_score: i16,
|
||||
/// The file's git status
|
||||
pub git_status: Option<git2::Status>,
|
||||
pub is_binary: bool,
|
||||
/// Lazily-initialized memory-mapped file contents for grep.
|
||||
|
||||
/// Absolute path stored as a plain String. We never use path components —
|
||||
/// only slicing, comparison, and passing to fs/DB APIs via `as_path()`.
|
||||
path: String,
|
||||
/// Byte offset where the relative path begins (after base_path + separator).
|
||||
relative_start: u16,
|
||||
/// Byte offset where the filename begins (after last separator).
|
||||
filename_start: u16,
|
||||
/// Packed boolean flags — see `FileItemFlags`.
|
||||
flags: u8,
|
||||
/// 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 {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
path: self.path.clone(),
|
||||
relative_path: self.relative_path.clone(),
|
||||
relative_path_lower: self.relative_path_lower.clone(),
|
||||
file_name: self.file_name.clone(),
|
||||
file_name_lower: self.file_name_lower.clone(),
|
||||
relative_start: self.relative_start,
|
||||
filename_start: self.filename_start,
|
||||
size: self.size,
|
||||
modified: self.modified,
|
||||
access_frecency_score: self.access_frecency_score,
|
||||
modification_frecency_score: self.modification_frecency_score,
|
||||
total_frecency_score: self.total_frecency_score,
|
||||
git_status: self.git_status,
|
||||
is_binary: self.is_binary,
|
||||
// Don't clone the mmap — the clone lazily re-creates it on demand
|
||||
mmap: OnceLock::new(),
|
||||
flags: self.flags,
|
||||
// Don't clone the content — the clone lazily re-creates it on demand
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
/// File content that is either borrowed from the persistent cache or owned
|
||||
/// from a temporary mmap. Dereferences to `&[u8]` so callers can use it
|
||||
/// transparently.
|
||||
///
|
||||
/// On Unix the uncached variant holds a temporary `memmap2::Mmap` that is
|
||||
/// backed by the kernel page cache — same zero-copy benefit as the cached
|
||||
/// path, but the mapping is released (munmap) as soon as this value is
|
||||
/// dropped instead of being retained for the lifetime of the `FileItem`.
|
||||
pub enum FileContentRef<'a> {
|
||||
/// Content is stored in the `FileItem`'s `OnceLock` cache (fast path).
|
||||
Cached(&'a [u8]),
|
||||
/// Temporary mmap (Unix) / heap buffer (Windows) created because the
|
||||
/// persistent cache budget was exceeded. Unmapped on drop.
|
||||
Temp(FileContent),
|
||||
}
|
||||
|
||||
impl std::ops::Deref for FileContentRef<'_> {
|
||||
type Target = [u8];
|
||||
fn deref(&self) -> &[u8] {
|
||||
match self {
|
||||
FileContentRef::Cached(s) => s,
|
||||
FileContentRef::Temp(c) => c,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileItem {
|
||||
/// Create a new `FileItem` with all fields specified and an empty (not yet loaded) mmap.
|
||||
pub fn new_raw(
|
||||
path: PathBuf,
|
||||
relative_path: String,
|
||||
file_name: String,
|
||||
path: String,
|
||||
relative_start: u16,
|
||||
filename_start: u16,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
is_binary: bool,
|
||||
) -> Self {
|
||||
let mut flags = 0u8;
|
||||
if is_binary {
|
||||
flags |= FileItemFlags::BINARY;
|
||||
}
|
||||
|
||||
Self {
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
file_name_lower: file_name.to_lowercase(),
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
is_binary,
|
||||
mmap: OnceLock::new(),
|
||||
flags,
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
|
||||
/// The full absolute path as a string slice.
|
||||
#[inline]
|
||||
pub fn path_str(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The full absolute path as a `&Path` (zero-cost on Unix).
|
||||
#[inline]
|
||||
pub fn as_path(&self) -> &Path {
|
||||
Path::new(&self.path)
|
||||
}
|
||||
|
||||
/// The relative path (from the base directory).
|
||||
#[inline]
|
||||
pub fn relative_path(&self) -> &str {
|
||||
&self.path[self.relative_start as usize..]
|
||||
}
|
||||
|
||||
/// Just the filename component.
|
||||
#[inline]
|
||||
pub fn file_name(&self) -> &str {
|
||||
&self.path[self.filename_start as usize..]
|
||||
}
|
||||
|
||||
/// Byte offset of the filename within the relative path.
|
||||
/// Equivalent to `relative_path().len() - file_name().len()`.
|
||||
#[inline]
|
||||
pub fn filename_offset_in_relative(&self) -> usize {
|
||||
(self.filename_start - self.relative_start) as usize
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn total_frecency_score(&self) -> i32 {
|
||||
self.access_frecency_score as i32 + self.modification_frecency_score as i32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_binary(&self) -> bool {
|
||||
self.flags & FileItemFlags::BINARY != 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_binary(&mut self, val: bool) {
|
||||
if val {
|
||||
self.flags |= FileItemFlags::BINARY;
|
||||
} else {
|
||||
self.flags &= !FileItemFlags::BINARY;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_deleted(&self) -> bool {
|
||||
self.flags & FileItemFlags::DELETED != 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_deleted(&mut self, val: bool) {
|
||||
if val {
|
||||
self.flags |= FileItemFlags::DELETED;
|
||||
} else {
|
||||
self.flags &= !FileItemFlags::DELETED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Matchable for FileItem {
|
||||
#[inline]
|
||||
fn match_str(&self) -> Option<&str> {
|
||||
(!self.is_deleted()).then(|| self.relative_path())
|
||||
}
|
||||
}
|
||||
|
||||
impl Matchable for &FileItem {
|
||||
#[inline]
|
||||
fn match_str(&self) -> Option<&str> {
|
||||
(!self.is_deleted()).then(|| self.relative_path())
|
||||
}
|
||||
}
|
||||
|
||||
impl FileItem {
|
||||
/// 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.
|
||||
pub fn invalidate_mmap(&mut self) {
|
||||
self.mmap = OnceLock::new();
|
||||
}
|
||||
|
||||
/// Get the cached mmap or lazily create it. Returns `None` if the file
|
||||
/// is too large, empty, or can't be opened/mapped.
|
||||
///
|
||||
/// After the first call, this is lock-free (just an atomic load + pointer deref).
|
||||
/// The mmap is backed by the kernel page cache and automatically reflects
|
||||
/// file modifications — no manual invalidation is needed.
|
||||
#[inline]
|
||||
pub fn get_mmap(&self) -> Option<&Mmap> {
|
||||
if let Some(mmap) = self.mmap.get() {
|
||||
return Some(mmap);
|
||||
/// 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, budget: &ContentCacheBudget) {
|
||||
if self.content.get().is_some() {
|
||||
budget.cached_count.fetch_sub(1, Ordering::Relaxed);
|
||||
budget.cached_bytes.fetch_sub(self.size, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
|
||||
self.content = OnceLock::new();
|
||||
}
|
||||
|
||||
/// Get the cached file contents or lazily load and cache them.
|
||||
///
|
||||
/// Returns `None` if the file is too large, empty, can't be opened, **or
|
||||
/// the cache budget is exhausted**. Callers that need content regardless
|
||||
/// of the budget should use [`get_content_for_search`].
|
||||
///
|
||||
/// After the first call, this is lock-free (just an atomic load + pointer deref).
|
||||
pub fn get_content(&self, budget: &ContentCacheBudget) -> Option<&[u8]> {
|
||||
if let Some(content) = self.content.get() {
|
||||
return Some(content);
|
||||
}
|
||||
|
||||
let max_file_size = budget.max_file_size;
|
||||
if self.size == 0 || self.size > max_file_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&self.path).ok()?;
|
||||
// SAFETY: The mmap is backed by the kernel page cache and automatically
|
||||
// reflects file modifications. The only risk is SIGBUS if the file is
|
||||
// truncated while mapped
|
||||
let mmap = unsafe { Mmap::map(&file) }.ok()?;
|
||||
// Check cache budget before creating a new persistent cache entry.
|
||||
let count = budget.cached_count.load(Ordering::Relaxed);
|
||||
let bytes = budget.cached_bytes.load(Ordering::Relaxed);
|
||||
let max_files = budget.max_files;
|
||||
let max_bytes = budget.max_bytes;
|
||||
if count >= max_files || bytes + self.size > max_bytes {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 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))
|
||||
let content = load_file_content(self.as_path(), self.size)?;
|
||||
let result = self.content.get_or_init(|| content);
|
||||
|
||||
// Bump counters. Slight over-count under races is fine — the budget
|
||||
// is a soft limit and the overshoot is bounded by rayon thread count.
|
||||
budget.cached_count.fetch_add(1, Ordering::Relaxed);
|
||||
budget.cached_bytes.fetch_add(self.size, Ordering::Relaxed);
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Get file content for searching — **always returns content** for eligible
|
||||
/// files, even when the persistent cache budget is exhausted.
|
||||
///
|
||||
/// Tries the `OnceLock` cache first (fast path). If the cache is full,
|
||||
/// falls back to a temporary mmap that is unmapped when the returned
|
||||
/// [`FileContentRef`] is dropped — no persistent kernel resources retained.
|
||||
#[inline]
|
||||
pub fn get_content_for_search<'a>(
|
||||
&'a self,
|
||||
budget: &ContentCacheBudget,
|
||||
) -> Option<FileContentRef<'a>> {
|
||||
if let Some(cached) = self.get_content(budget) {
|
||||
return Some(FileContentRef::Cached(cached));
|
||||
}
|
||||
|
||||
// get_content returned None — either ineligible or over budget.
|
||||
let max_file_size = budget.max_file_size;
|
||||
if self.is_binary() || self.size == 0 || self.size > max_file_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Over budget: create a temporary mmap that is unmapped on drop.
|
||||
let content = load_file_content(self.as_path(), self.size)?;
|
||||
Some(FileContentRef::Temp(content))
|
||||
}
|
||||
}
|
||||
|
||||
/// Page size on Apple Silicon is 16KB; on x86-64 it's 4KB.
|
||||
/// Files smaller than one page waste the remainder when mmapped.
|
||||
/// Reading them into a heap buffer avoids this overhead.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
const MMAP_THRESHOLD: u64 = 16 * 1024;
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
const MMAP_THRESHOLD: u64 = 4 * 1024;
|
||||
|
||||
/// Load file contents: small files are read into a heap buffer to avoid
|
||||
/// mmap page alignment waste; large files use mmap for zero-copy access.
|
||||
/// On Windows, always uses heap buffer (mmap holds the file handle open).
|
||||
fn load_file_content(path: &Path, size: u64) -> Option<FileContent> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
if size < MMAP_THRESHOLD {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
Some(FileContent::Buffer(data))
|
||||
} else {
|
||||
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 { memmap2::Mmap::map(&file) }.ok()?;
|
||||
Some(FileContent::Mmap(mmap))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = size;
|
||||
let data = std::fs::read(path).ok()?;
|
||||
Some(FileContent::Buffer(data))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for FileItem {
|
||||
#[inline]
|
||||
fn as_ref(&self) -> &Path {
|
||||
Path::new(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Constrainable for FileItem {
|
||||
#[inline]
|
||||
fn relative_path(&self) -> &str {
|
||||
&self.relative_path
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn relative_path_lower(&self) -> &str {
|
||||
&self.relative_path_lower
|
||||
FileItem::relative_path(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn file_name(&self) -> &str {
|
||||
&self.file_name
|
||||
FileItem::file_name(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -148,13 +371,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 +392,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,3 +440,91 @@ pub struct SearchResult<'a> {
|
||||
pub total_files: usize,
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
// Limits the total number of files (and bytes) whose content is kept in
|
||||
// memory via the `OnceLock<FileContent>` cache. On Unix every cached file
|
||||
// holds a live `mmap`, which consumes a kernel `vm_map_entry`. On a 500k-file
|
||||
// monorepo, caching everything exhausts macOS/Linux kernel resources and
|
||||
// crashes the machine (see issue #294).
|
||||
//
|
||||
// Each `FilePicker` owns its own `ContentCacheBudget`. The budget is passed
|
||||
// to `grep_search` and `warmup_mmaps` so that multiple pickers can coexist
|
||||
// without interfering with each other's counters.
|
||||
|
||||
const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Per-picker budget controlling how many files may have their content
|
||||
/// persistently cached (mmap on Unix, heap buffer on Windows).
|
||||
#[derive(Debug)]
|
||||
pub struct ContentCacheBudget {
|
||||
pub max_files: usize,
|
||||
pub max_bytes: u64,
|
||||
pub max_file_size: u64,
|
||||
pub cached_count: AtomicUsize,
|
||||
pub cached_bytes: AtomicU64,
|
||||
}
|
||||
|
||||
impl ContentCacheBudget {
|
||||
/// No limits — every eligible file is cached. Useful for tests and
|
||||
/// short-lived tools that don't need resource protection.
|
||||
pub fn unlimited() -> Self {
|
||||
Self {
|
||||
max_files: usize::MAX,
|
||||
max_bytes: u64::MAX,
|
||||
max_file_size: MAX_MMAP_FILE_SIZE,
|
||||
cached_count: AtomicUsize::new(0),
|
||||
cached_bytes: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
max_files: 0,
|
||||
max_bytes: 0,
|
||||
max_file_size: 0,
|
||||
cached_count: AtomicUsize::new(0),
|
||||
cached_bytes: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_repo(file_count: usize) -> Self {
|
||||
let max_files = if file_count > 50_000 {
|
||||
5_000
|
||||
} else if file_count > 10_000 {
|
||||
10_000
|
||||
} else {
|
||||
30_000 // effectively unlimited for small repos
|
||||
};
|
||||
|
||||
let max_bytes = if file_count > 50_000 {
|
||||
128 * 1024 * 1024 // 128 MB
|
||||
} else if file_count > 10_000 {
|
||||
256 * 1024 * 1024 // 256 MB
|
||||
} else {
|
||||
MAX_CACHED_CONTENT_BYTES // 512 MB
|
||||
};
|
||||
|
||||
Self {
|
||||
max_files,
|
||||
max_bytes,
|
||||
max_file_size: MAX_MMAP_FILE_SIZE,
|
||||
cached_count: AtomicUsize::new(0),
|
||||
cached_bytes: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the counters. Called when the file index is rebuilt (rescan /
|
||||
/// directory change) and all old `FileItem`s are dropped.
|
||||
pub fn reset(&self) {
|
||||
self.cached_count.store(0, Ordering::Relaxed);
|
||||
self.cached_bytes.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ContentCacheBudget {
|
||||
fn default() -> Self {
|
||||
Self::new_for_repo(30_000)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
//! Integration test: verify that modifying a file after the bigram index is built
|
||||
//! still makes the new content findable via grep (through the overlay layer).
|
||||
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{FilePickerOptions, SharedFrecency, SharedPicker};
|
||||
|
||||
/// Create a temp directory with some initial files, run the full picker lifecycle,
|
||||
/// then modify a file and verify grep finds the new content.
|
||||
#[test]
|
||||
fn modified_file_findable_via_overlay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
// Create initial files with known content.
|
||||
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
|
||||
fs::write(
|
||||
base.join("beta.txt"),
|
||||
"some other content\nnothing special\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("gamma.txt"), "yet another file\nmore lines\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
// Wait for scan + bigram build to complete.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let ready = shared_picker
|
||||
.read()
|
||||
.ok()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if ready {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Timed out waiting for scan + bigram build"
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity check: the 3 files are indexed.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
assert_eq!(picker.get_files().len(), 3, "Expected 3 files after scan");
|
||||
assert!(
|
||||
picker.bigram_index().is_some(),
|
||||
"Bigram index should be built"
|
||||
);
|
||||
assert!(
|
||||
picker.bigram_overlay().is_some(),
|
||||
"Overlay should be initialized"
|
||||
);
|
||||
}
|
||||
|
||||
// "UNIQUE_NEEDLE" should NOT exist in any file yet.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let parsed = parse_grep_query("UNIQUE_NEEDLE");
|
||||
let opts = grep_opts();
|
||||
let result = picker.grep(&parsed, &opts);
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
0,
|
||||
"UNIQUE_NEEDLE should not exist before modification"
|
||||
);
|
||||
}
|
||||
|
||||
// Sleep so the filesystem mtime (seconds granularity) advances past the
|
||||
// value recorded during scan. Without this, on_create_or_modify skips
|
||||
// mmap invalidation and grep reads stale cached content.
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
|
||||
// Write new content containing the needle.
|
||||
let modified_path = base.join("beta.txt");
|
||||
fs::write(
|
||||
&modified_path,
|
||||
"some other content\nUNIQUE_NEEDLE is here\nnothing special\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Simulate watcher event: call on_create_or_modify.
|
||||
// This updates the overlay's bigrams and invalidates the mmap cache.
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
let result = picker.on_create_or_modify(&modified_path);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"on_create_or_modify should return the file"
|
||||
);
|
||||
}
|
||||
|
||||
// The bigram index was built BEFORE the modification, so without the
|
||||
// overlay, beta.txt would be filtered out (its old bigrams don't contain
|
||||
// "UNIQUE_NEEDLE"). The overlay should fix that.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let parsed = parse_grep_query("UNIQUE_NEEDLE");
|
||||
let opts = grep_opts();
|
||||
let result = picker.grep(&parsed, &opts);
|
||||
assert!(
|
||||
!result.matches.is_empty(),
|
||||
"UNIQUE_NEEDLE should be findable after modification (overlay adds the candidate back)"
|
||||
);
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert!(result.matches[0].line_content.contains("UNIQUE_NEEDLE"));
|
||||
}
|
||||
|
||||
// Prove the overlay is actually doing something: without it, the bigram
|
||||
// index would filter out beta.txt and the search would miss the needle.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let parsed = parse_grep_query("UNIQUE_NEEDLE");
|
||||
let opts = grep_opts();
|
||||
let result = picker.grep_without_overlay(&parsed, &opts);
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
0,
|
||||
"Without overlay, bigram prefiltering should exclude the modified file"
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup: stop background watcher.
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that deleting a file makes its content un-findable via grep.
|
||||
#[test]
|
||||
fn deleted_file_excluded_via_overlay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
fs::write(base.join("keep.txt"), "keep this content\n").unwrap();
|
||||
fs::write(base.join("remove.txt"), "DELETEME_TOKEN is here\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
wait_for_bigram(&shared_picker);
|
||||
|
||||
// Sanity: DELETEME_TOKEN is findable.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let result = grep_for(picker, "DELETEME_TOKEN");
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
1,
|
||||
"Token should be found before delete"
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the file on disk and via picker.
|
||||
let remove_path = base.join("remove.txt");
|
||||
fs::remove_file(&remove_path).unwrap();
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(
|
||||
picker.remove_file_by_path(&remove_path),
|
||||
"remove should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
// Token should no longer be found (tombstone in overlay clears the candidate).
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let result = grep_for(picker, "DELETEME_TOKEN");
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
0,
|
||||
"DELETEME_TOKEN should not be found after deletion (tombstone in overlay)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that a newly added file (in overflow) is findable via grep.
|
||||
#[test]
|
||||
fn new_file_findable_after_add() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
fs::write(base.join("existing.txt"), "original content\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
wait_for_bigram(&shared_picker);
|
||||
|
||||
// Create a new file on disk after the index was built.
|
||||
let new_path = base.join("newcomer.txt");
|
||||
fs::write(&new_path, "BRAND_NEW_TOKEN lives here\n").unwrap();
|
||||
|
||||
// Simulate watcher detecting the new file.
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
let result = picker.on_create_or_modify(&new_path);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"on_create_or_modify should return the new file"
|
||||
);
|
||||
}
|
||||
|
||||
// The new file is in overflow, not in the base files slice.
|
||||
// grep_search currently only searches base files, so we need to verify
|
||||
// the overflow file is accessible.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let overflow = picker.get_overflow_files();
|
||||
assert_eq!(overflow.len(), 1, "Should have 1 overflow file");
|
||||
assert!(
|
||||
overflow[0].as_path().ends_with("newcomer.txt"),
|
||||
"Overflow file should be newcomer.txt"
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn grep_opts() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 200,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_for<'a>(picker: &'a FilePicker, query: &str) -> fff_search::grep::GrepResult<'a> {
|
||||
let parsed = parse_grep_query(query);
|
||||
picker.grep(&parsed, &grep_opts())
|
||||
}
|
||||
|
||||
fn wait_for_bigram(shared_picker: &SharedPicker) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let ready = shared_picker
|
||||
.read()
|
||||
.ok()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if ready {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Timed out waiting for bigram build"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fff-grep"
|
||||
description = "File grepping logic for fff"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
|
||||
memchr = "2.6.3"
|
||||
@@ -8,10 +8,12 @@ Only `search_slice` is supported -- no file/reader/mmap search.
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub use crate::{
|
||||
matcher::{LineTerminator, Match, Matcher, NoError},
|
||||
searcher::{Searcher, SearcherBuilder},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
pub mod lines;
|
||||
pub mod matcher;
|
||||
mod searcher;
|
||||
mod sink;
|
||||
@@ -2,10 +2,9 @@
|
||||
A collection of routines for performing operations on lines.
|
||||
*/
|
||||
|
||||
use {
|
||||
bstr::ByteSlice,
|
||||
grep_matcher::{LineTerminator, Match},
|
||||
};
|
||||
use bstr::ByteSlice;
|
||||
|
||||
use crate::matcher::{LineTerminator, Match};
|
||||
|
||||
/// An explicit iterator over lines in a particular slice of bytes.
|
||||
///
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Matcher trait inspired by ripgrep's `Matcher` just simpler
|
||||
|
||||
/// A byte range representing a match.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Match {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl Match {
|
||||
/// Create a new match from start/end byte offsets.
|
||||
#[inline]
|
||||
pub fn new(start: usize, end: usize) -> Match {
|
||||
debug_assert!(start <= end);
|
||||
Match { start, end }
|
||||
}
|
||||
|
||||
/// Create a zero-width match at `offset`.
|
||||
#[inline]
|
||||
pub fn zero(offset: usize) -> Match {
|
||||
Match {
|
||||
start: offset,
|
||||
end: offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start byte offset.
|
||||
#[inline]
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
|
||||
/// End byte offset (exclusive).
|
||||
#[inline]
|
||||
pub fn end(&self) -> usize {
|
||||
self.end
|
||||
}
|
||||
|
||||
/// Return a copy with a different end offset.
|
||||
#[inline]
|
||||
pub fn with_end(&self, end: usize) -> Match {
|
||||
debug_assert!(self.start <= end);
|
||||
Match { end, ..*self }
|
||||
}
|
||||
|
||||
/// Shift both offsets forward by `amount`.
|
||||
#[inline]
|
||||
pub fn offset(&self, amount: usize) -> Match {
|
||||
Match {
|
||||
start: self.start + amount,
|
||||
end: self.end + amount,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte length of the match.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
/// True if this is a zero-width match.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Index<Match> for [u8] {
|
||||
type Output = [u8];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: Match) -> &[u8] {
|
||||
&self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::IndexMut<Match> for [u8] {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: Match) -> &mut [u8] {
|
||||
&mut self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Index<Match> for str {
|
||||
type Output = str;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: Match) -> &str {
|
||||
&self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
/// A line terminator (always a single byte for fff — no CRLF support needed).
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct LineTerminator(u8);
|
||||
|
||||
impl LineTerminator {
|
||||
/// Create a line terminator from a single byte.
|
||||
#[inline]
|
||||
pub fn byte(byte: u8) -> LineTerminator {
|
||||
LineTerminator(byte)
|
||||
}
|
||||
|
||||
/// Return the terminator byte.
|
||||
#[inline]
|
||||
pub fn as_byte(&self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Return the terminator as a single-element byte slice.
|
||||
#[inline]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
std::slice::from_ref(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LineTerminator {
|
||||
#[inline]
|
||||
fn default() -> LineTerminator {
|
||||
LineTerminator(b'\n')
|
||||
}
|
||||
}
|
||||
|
||||
/// An error type for matchers that never produce errors.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct NoError(());
|
||||
|
||||
impl std::error::Error for NoError {}
|
||||
|
||||
impl std::fmt::Display for NoError {
|
||||
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
unreachable!("NoError should never be instantiated")
|
||||
}
|
||||
}
|
||||
|
||||
/// A matcher finds byte-level matches in a haystack.
|
||||
pub trait Matcher {
|
||||
/// The error type (use [`NoError`] for infallible matchers).
|
||||
type Error: std::fmt::Display;
|
||||
|
||||
/// Find the first match at or after `at` in `haystack`.
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error>;
|
||||
|
||||
/// Find the first match in `haystack`.
|
||||
#[inline]
|
||||
fn find(&self, haystack: &[u8]) -> Result<Option<Match>, Self::Error> {
|
||||
self.find_at(haystack, 0)
|
||||
}
|
||||
|
||||
/// The line terminator this matcher guarantees will never appear in a match.
|
||||
/// Return `None` if the matcher can match across lines.
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<LineTerminator> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Matcher> Matcher for &M {
|
||||
type Error = M::Error;
|
||||
|
||||
#[inline]
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error> {
|
||||
(*self).find_at(haystack, at)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn find(&self, haystack: &[u8]) -> Result<Option<Match>, Self::Error> {
|
||||
(*self).find(haystack)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<LineTerminator> {
|
||||
(*self).line_terminator()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use crate::{
|
||||
lines,
|
||||
matcher::Matcher,
|
||||
searcher::{Config, Range, Searcher},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Core<'s, M: 's, S> {
|
||||
config: &'s Config,
|
||||
matcher: M,
|
||||
searcher: &'s Searcher,
|
||||
sink: S,
|
||||
pos: usize,
|
||||
absolute_byte_offset: u64,
|
||||
line_number: Option<u64>,
|
||||
last_line_counted: usize,
|
||||
last_line_visited: usize,
|
||||
}
|
||||
|
||||
impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
pub(crate) fn new(searcher: &'s Searcher, matcher: M, sink: S) -> Core<'s, M, S> {
|
||||
let line_number = if searcher.config.line_number {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Core {
|
||||
config: &searcher.config,
|
||||
matcher,
|
||||
searcher,
|
||||
sink,
|
||||
pos: 0,
|
||||
absolute_byte_offset: 0,
|
||||
line_number,
|
||||
last_line_counted: 0,
|
||||
last_line_visited: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pos(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
pub(crate) fn set_pos(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
pub(crate) fn matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.sink_matched(buf, range)
|
||||
}
|
||||
|
||||
pub(crate) fn find(&mut self, slice: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
self.matcher.find(slice).map_err(S::Error::error_message)
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self) -> Result<bool, S::Error> {
|
||||
self.sink.begin(self.searcher)
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self, byte_count: u64) -> Result<(), S::Error> {
|
||||
self.sink.finish(self.searcher, &SinkFinish { byte_count })
|
||||
}
|
||||
|
||||
pub(crate) fn match_by_line(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
while !buf[self.pos()..].is_empty() {
|
||||
if let Some(line) = self.find_by_line(buf)? {
|
||||
self.set_pos(line.end());
|
||||
if !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.set_pos(buf.len());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn find_by_line(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
let mut pos = self.pos();
|
||||
while !buf[pos..].is_empty() {
|
||||
let mat = match self
|
||||
.matcher
|
||||
.find(&buf[pos..])
|
||||
.map_err(S::Error::error_message)?
|
||||
{
|
||||
None => return Ok(None),
|
||||
Some(m) => m,
|
||||
};
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(mat.start()).offset(pos),
|
||||
);
|
||||
if line.start() == buf.len() {
|
||||
pos = buf.len();
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(line));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn sink_matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.count_lines(buf, range.start());
|
||||
let offset = self.absolute_byte_offset + range.start() as u64;
|
||||
let linebuf = &buf[*range];
|
||||
let keepgoing = self.sink.matched(
|
||||
self.searcher,
|
||||
&SinkMatch {
|
||||
bytes: linebuf,
|
||||
absolute_byte_offset: offset,
|
||||
line_number: self.line_number,
|
||||
buffer: buf,
|
||||
bytes_range_in_buffer: range.start()..range.end(),
|
||||
},
|
||||
)?;
|
||||
if !keepgoing {
|
||||
return Ok(false);
|
||||
}
|
||||
self.last_line_visited = range.end();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn count_lines(&mut self, buf: &[u8], upto: usize) {
|
||||
if let Some(ref mut line_number) = self.line_number {
|
||||
if self.last_line_counted >= upto {
|
||||
return;
|
||||
}
|
||||
let slice = &buf[self.last_line_counted..upto];
|
||||
let count = lines::count(slice, self.config.line_term.as_byte());
|
||||
*line_number += count;
|
||||
self.last_line_counted = upto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use grep_matcher::Matcher;
|
||||
|
||||
use crate::{
|
||||
lines,
|
||||
matcher::Matcher,
|
||||
searcher::{Config, Range, Searcher, core::Core},
|
||||
sink::Sink,
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
use grep_matcher::{LineTerminator, Match, Matcher};
|
||||
|
||||
use crate::{
|
||||
matcher::{LineTerminator, Match, Matcher},
|
||||
searcher::glue::{MultiLine, SliceByLine},
|
||||
sink::{Sink, SinkError},
|
||||
};
|
||||
@@ -190,11 +189,6 @@ impl Searcher {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(non_matching) = matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.line_terminator().as_byte())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "fff-mcp"
|
||||
version = "0.5.2"
|
||||
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.5.1" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.5.2" }
|
||||
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", "env"] }
|
||||
@@ -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,117 @@
|
||||
use crate::Args;
|
||||
use git2::Repository;
|
||||
|
||||
fn check(label: &str, ok: bool, detail: &str) -> bool {
|
||||
let marker = if ok { "+" } else { "x" };
|
||||
println!(" [{marker}] {label}: {detail}");
|
||||
ok
|
||||
}
|
||||
|
||||
fn warn(label: &str, detail: &str) {
|
||||
println!(" [!] {label}: {detail}");
|
||||
}
|
||||
|
||||
pub fn run_healthcheck(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")");
|
||||
println!("fff-mcp {version}\n");
|
||||
|
||||
let mut all_ok = true;
|
||||
|
||||
// 1. Base path
|
||||
let base_path = args.base_path.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let path_exists = std::path::Path::new(&base_path).is_dir();
|
||||
all_ok &= check(
|
||||
"Base path",
|
||||
path_exists,
|
||||
if path_exists {
|
||||
&base_path
|
||||
} else {
|
||||
"directory does not exist"
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Git repository
|
||||
match Repository::discover(&base_path) {
|
||||
Ok(repo) => {
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
all_ok &= check("Git repository", true, &format!("{}", workdir.display()));
|
||||
} else {
|
||||
all_ok &= check("Git repository", true, "bare repository");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Not fatal — fff-mcp works without git, but worth flagging.
|
||||
warn(
|
||||
"Git repository",
|
||||
"not found (fff-mcp will still work, but git-status features are disabled)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Frecency database
|
||||
if let Some(ref db_path) = args.frecency_db_path {
|
||||
let parent_ok = std::path::Path::new(db_path)
|
||||
.parent()
|
||||
.is_some_and(|p| p.is_dir());
|
||||
all_ok &= check(
|
||||
"Frecency DB",
|
||||
parent_ok,
|
||||
if parent_ok {
|
||||
db_path
|
||||
} else {
|
||||
"parent directory does not exist"
|
||||
},
|
||||
);
|
||||
} else {
|
||||
check("Frecency DB", false, "path not resolved");
|
||||
}
|
||||
|
||||
// 4. Query history database
|
||||
if let Some(ref db_path) = args.history_db_path {
|
||||
let parent_ok = std::path::Path::new(db_path)
|
||||
.parent()
|
||||
.is_some_and(|p| p.is_dir());
|
||||
all_ok &= check(
|
||||
"History DB",
|
||||
parent_ok,
|
||||
if parent_ok {
|
||||
db_path
|
||||
} else {
|
||||
"parent directory does not exist"
|
||||
},
|
||||
);
|
||||
} else {
|
||||
check("History DB", false, "path not resolved");
|
||||
}
|
||||
|
||||
// 5. Log file
|
||||
if let Some(ref log_path) = args.log_file {
|
||||
let parent_ok = std::path::Path::new(log_path)
|
||||
.parent()
|
||||
.is_some_and(|p| p.is_dir());
|
||||
all_ok &= check(
|
||||
"Log file",
|
||||
parent_ok,
|
||||
if parent_ok {
|
||||
log_path
|
||||
} else {
|
||||
"parent directory does not exist"
|
||||
},
|
||||
);
|
||||
} else {
|
||||
check("Log file", false, "path not resolved");
|
||||
}
|
||||
|
||||
if all_ok {
|
||||
println!("All checks passed.");
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Some checks failed — review the items marked [x] above.".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! 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 healthcheck;
|
||||
mod output;
|
||||
mod server;
|
||||
mod update_check;
|
||||
|
||||
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"), ")"))]
|
||||
pub(crate) 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,
|
||||
|
||||
/// Disable eager mmap warmup after the initial scan. Grep results will
|
||||
/// still work (files are mmap'd lazily on first access), but the first
|
||||
/// search may be slightly slower. Useful on very large repos where the
|
||||
/// warmup would consume too many kernel resources.
|
||||
#[arg(long = "no-warmup")]
|
||||
no_warmup: bool,
|
||||
|
||||
/// Maximum number of files whose content is kept persistently in memory.
|
||||
/// Files beyond this limit are still searchable via temporary mmaps that
|
||||
/// are released after each grep. Defaults to 30 000.
|
||||
/// Also settable via the FFF_MAX_CACHED_FILES environment variable.
|
||||
#[arg(long = "max-cached-files", env = "FFF_MAX_CACHED_FILES")]
|
||||
max_cached_files: Option<usize>,
|
||||
|
||||
/// Run a health check and print diagnostic information, then exit.
|
||||
#[arg(long = "healthcheck")]
|
||||
pub(crate) healthcheck: 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);
|
||||
|
||||
if args.healthcheck {
|
||||
return healthcheck::run_healthcheck(&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()
|
||||
});
|
||||
|
||||
let base_path = match Repository::discover(&base_path) {
|
||||
Ok(repo) => {
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
let git_root = workdir.to_string_lossy().to_string();
|
||||
tracing::info!("Discovered git root: {}", git_root);
|
||||
git_root
|
||||
} else {
|
||||
tracing::info!("Git repository is bare, using base path: {}", base_path);
|
||||
base_path
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::info!(
|
||||
"No git repository found, indexing from base path: {}",
|
||||
base_path
|
||||
);
|
||||
base_path
|
||||
}
|
||||
};
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
match FrecencyTracker::new(&frecency_db_path, false) {
|
||||
Ok(tracker) => {
|
||||
let _ = shared_frecency.init(tracker);
|
||||
let _ = shared_frecency.spawn_gc(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(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path,
|
||||
warmup_mmap_cache: !args.no_warmup,
|
||||
mode: FFFMode::Ai,
|
||||
cache_budget: args
|
||||
.max_cached_files
|
||||
.map(fff::ContentCacheBudget::new_for_repo),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.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 = shared_picker.clone();
|
||||
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: i32) -> 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: i32) -> 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() != 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())
|
||||
{
|
||||
let expand_limit = if def_expanded_files.is_empty() {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_files.insert(file.relative_path());
|
||||
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();
|
||||
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,684 @@
|
||||
//! 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 crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
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};
|
||||
|
||||
/// 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")]
|
||||
// this has to be float because llms are stupid
|
||||
pub max_results: Option<f64>,
|
||||
/// 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<f64>, // this has to be float because llms are stupid
|
||||
/// 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<f64>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
#[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 parser = QueryParser::new(AiGrepConfig);
|
||||
let parsed = parser.parse(query);
|
||||
let result = picker.grep(&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 = picker.grep(&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 = picker.grep(&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() != 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(picker.get_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.0).round() as usize; // safe
|
||||
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.0) as usize;
|
||||
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.0).round() as usize;
|
||||
let context = params.context.map(|v| v.round() as usize);
|
||||
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, 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 budget = picker.cache_budget();
|
||||
let result =
|
||||
grep::multi_grep_search(files, &patterns_refs, constraints, &options, budget, None);
|
||||
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, 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, budget, None, None, None);
|
||||
|
||||
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.5.2"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
zlob = ["fff/zlob"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
@@ -35,6 +35,11 @@ path = "src/bin/grep_profiler.rs"
|
||||
name = "grep_vs_rg"
|
||||
path = "src/bin/grep_vs_rg.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_grep_query"
|
||||
ppath = "src/bin/bench_grep_query.rs"
|
||||
path = "src/bin/bench_grep_query.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
@@ -44,8 +49,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.5.1", features = ["mimalloc-collect"] }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.5.2" }
|
||||
|
||||
# External dependencies
|
||||
blake3 = "1.8.2"
|
||||
@@ -65,8 +70,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"] }
|
||||
@@ -80,4 +83,3 @@ harness = false
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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::{ContentCacheBudget, FileItem, PaginationArgs};
|
||||
use fff::{
|
||||
FilePickerOptions, FuzzySearchOptions, GrepMode, GrepSearchOptions, QueryParser,
|
||||
SharedFrecency, SharedPicker, build_bigram_index, grep,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Initialize tracing to output to console
|
||||
@@ -27,10 +29,14 @@ fn init_file_picker_internal(
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), String> {
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string(),
|
||||
false,
|
||||
Arc::clone(shared_picker),
|
||||
Arc::clone(shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: path.to_string(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
|
||||
}
|
||||
@@ -128,12 +134,12 @@ 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);
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
init_file_picker_internal(
|
||||
&canonical_path.to_string_lossy(),
|
||||
@@ -164,7 +170,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);
|
||||
@@ -178,8 +184,8 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("index_big_repo", |b| {
|
||||
b.iter(|| {
|
||||
let sp: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let sf: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let sp = SharedPicker::default();
|
||||
let sf = SharedFrecency::default();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
|
||||
@@ -225,19 +231,21 @@ 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),
|
||||
black_box(&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 {
|
||||
@@ -268,6 +276,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,14 +288,13 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: threads,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -317,6 +326,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,14 +335,13 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(&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 {
|
||||
@@ -350,8 +360,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 +380,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,14 +394,13 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(subset),
|
||||
black_box(query),
|
||||
black_box(&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 {
|
||||
@@ -419,21 +430,22 @@ 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),
|
||||
black_box(&parsed_controller),
|
||||
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 {
|
||||
@@ -451,14 +463,13 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(&parsed_controller),
|
||||
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 {
|
||||
@@ -476,14 +487,13 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
black_box(&parsed_mod),
|
||||
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 {
|
||||
@@ -500,14 +510,13 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
black_box(&parsed_mod),
|
||||
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 {
|
||||
@@ -525,14 +534,13 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
black_box(&parsed_controller),
|
||||
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 {
|
||||
@@ -549,14 +557,13 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
black_box(&parsed_controller),
|
||||
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 {
|
||||
@@ -586,6 +593,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,14 +602,13 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(&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 {
|
||||
@@ -618,14 +626,13 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(&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 {
|
||||
@@ -643,14 +650,13 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(&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 {
|
||||
@@ -666,6 +672,90 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark grep search with bigram index prefiltering
|
||||
fn bench_grep_search(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping grep benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let budget = ContentCacheBudget::new_for_repo(files.len());
|
||||
|
||||
eprintln!(" Building bigram index for {} files...", files.len());
|
||||
let start = std::time::Instant::now();
|
||||
let (bigram_filter, _overflow_indices) = build_bigram_index(&files, &budget);
|
||||
eprintln!(
|
||||
" Bigram index built in {:.2}s ({} columns)",
|
||||
start.elapsed().as_secs_f64(),
|
||||
bigram_filter.columns_used(),
|
||||
);
|
||||
|
||||
let mut group = c.benchmark_group("grep");
|
||||
group.sample_size(50);
|
||||
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 0,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 100,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let test_queries = vec![
|
||||
("common", "struct"),
|
||||
("specific", "DEFINE_MUTEX"),
|
||||
("path_filter", "*.h mutex"),
|
||||
];
|
||||
|
||||
let grep_parser = fff::QueryParser::new(fff::GrepConfig);
|
||||
|
||||
for (name, query) in &test_queries {
|
||||
let parsed = grep_parser.parse(query);
|
||||
|
||||
// With bigram index
|
||||
group.bench_with_input(BenchmarkId::new("with_bigram", name), query, |b, _| {
|
||||
b.iter(|| {
|
||||
let result = grep::grep_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
black_box(&options),
|
||||
&budget,
|
||||
Some(&bigram_filter),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
result.matches.len()
|
||||
});
|
||||
});
|
||||
|
||||
// Without bigram index
|
||||
group.bench_with_input(BenchmarkId::new("without_bigram", name), query, |b, _| {
|
||||
b.iter(|| {
|
||||
let result = grep::grep_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
black_box(&options),
|
||||
&budget,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
result.matches.len()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_indexing,
|
||||
@@ -675,6 +765,7 @@ criterion_group!(
|
||||
bench_search_scalability,
|
||||
bench_search_ordering,
|
||||
bench_pagination_performance,
|
||||
bench_grep_search,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/// Benchmark: AVX2 vs scalar case-insensitive memmem prefilter.
|
||||
///
|
||||
/// Loads all non-binary file contents from a repo, then times both
|
||||
/// implementations scanning every file for the query.
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin bench_ci_memmem
|
||||
/// ./target/release/bench_ci_memmem --path ./big-repo --query "nomore" --iters 5
|
||||
use fff::case_insensitive_memmem;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn fmt_dur(us: u128) -> String {
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", us as f64 / 1_000_000.0)
|
||||
} else if us > 1000 {
|
||||
format!("{:.2}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}µs", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn stats(times_us: &mut [u128]) -> (u128, u128, u128, u128) {
|
||||
times_us.sort();
|
||||
let sum: u128 = times_us.iter().sum();
|
||||
let mean = sum / times_us.len() as u128;
|
||||
let median = times_us[times_us.len() / 2];
|
||||
(mean, median, times_us[0], times_us[times_us.len() - 1])
|
||||
}
|
||||
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
}
|
||||
|
||||
fn load_file_contents(base_path: &Path) -> Vec<Vec<u8>> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut contents = Vec::new();
|
||||
let max_size = 10 * 1024 * 1024u64;
|
||||
|
||||
WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
|
||||
.for_each(|entry| {
|
||||
let path = entry.path();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
if size == 0 || size > max_size || detect_binary(path, size) {
|
||||
return;
|
||||
}
|
||||
if let Ok(data) = std::fs::read(path) {
|
||||
contents.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
contents
|
||||
}
|
||||
|
||||
fn bench_impl(
|
||||
label: &str,
|
||||
contents: &[Vec<u8>],
|
||||
needle_lower: &[u8],
|
||||
total_bytes: u64,
|
||||
iters: usize,
|
||||
search_fn: fn(&[u8], &[u8]) -> bool,
|
||||
) {
|
||||
eprintln!("\n [{}]", label);
|
||||
let mut times = Vec::with_capacity(iters);
|
||||
let mut hit_count = 0u32;
|
||||
|
||||
for i in 0..iters {
|
||||
let t = Instant::now();
|
||||
let mut hits = 0u32;
|
||||
for content in contents {
|
||||
if search_fn(content, needle_lower) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
let us = t.elapsed().as_micros();
|
||||
times.push(us);
|
||||
hit_count = hits;
|
||||
let tp = total_bytes as f64 / (us as f64 / 1_000_000.0) / (1024.0 * 1024.0 * 1024.0);
|
||||
eprintln!(
|
||||
" iter {}: {} ({} hits, {:.2} GB/s)",
|
||||
i + 1,
|
||||
fmt_dur(us),
|
||||
hits,
|
||||
tp
|
||||
);
|
||||
}
|
||||
|
||||
let (mean, median, min, max) = stats(&mut times);
|
||||
let med_tp = total_bytes as f64 / (median as f64 / 1_000_000.0) / (1024.0 * 1024.0 * 1024.0);
|
||||
eprintln!(
|
||||
" mean: {} median: {} ({:.2} GB/s) min: {} max: {} hits: {}",
|
||||
fmt_dur(mean),
|
||||
fmt_dur(median),
|
||||
med_tp,
|
||||
fmt_dur(min),
|
||||
fmt_dur(max),
|
||||
hit_count
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let path = args
|
||||
.iter()
|
||||
.position(|a| a == "--path")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(".");
|
||||
|
||||
let query = args
|
||||
.iter()
|
||||
.position(|a| a == "--query")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("TODO");
|
||||
|
||||
let iters: usize = args
|
||||
.iter()
|
||||
.position(|a| a == "--iters")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let repo = std::path::PathBuf::from(path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Path not found: {}", path);
|
||||
eprintln!("Usage: bench_ci_memmem --path <dir> --query <text> [--iters N]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
|
||||
let needle_lower: Vec<u8> = query.bytes().map(|b| b.to_ascii_lowercase()).collect();
|
||||
|
||||
eprintln!("=== bench_ci_memmem: AVX2 vs Scalar ===");
|
||||
eprintln!("Path: {}", canonical.display());
|
||||
eprintln!("Query: \"{}\"", query);
|
||||
eprintln!("Needle: {:?}", std::str::from_utf8(&needle_lower).unwrap());
|
||||
eprintln!("Iters: {}", iters);
|
||||
|
||||
eprint!("\n[1/2] Loading files into memory... ");
|
||||
let t = Instant::now();
|
||||
let contents = load_file_contents(&canonical);
|
||||
let total_bytes: u64 = contents.iter().map(|c| c.len() as u64).sum();
|
||||
eprintln!(
|
||||
"{} files, {:.1} MB in {:.2}s",
|
||||
contents.len(),
|
||||
total_bytes as f64 / (1024.0 * 1024.0),
|
||||
t.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
eprintln!("\n[2/2] Benchmarking memmem prefilter (scanning ALL files)");
|
||||
|
||||
bench_impl(
|
||||
"Packed pair: (AVX2 two-byte scan)",
|
||||
&contents,
|
||||
&needle_lower,
|
||||
total_bytes,
|
||||
iters,
|
||||
case_insensitive_memmem::search_packed_pair,
|
||||
);
|
||||
|
||||
bench_impl(
|
||||
"scalar: memchr2 first-byte + AVX2 verify",
|
||||
&contents,
|
||||
&needle_lower,
|
||||
total_bytes,
|
||||
iters,
|
||||
case_insensitive_memmem::search,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/// Single-query grep benchmark with bigram index profiling.
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin bench_grep_query
|
||||
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "MAX_FILE_SIZE" --iters 3
|
||||
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "TODO" --no-bigram
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use fff::types::ContentCacheBudget;
|
||||
use std::time::Instant;
|
||||
|
||||
fn fmt_dur(us: u128) -> String {
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", us as f64 / 1_000_000.0)
|
||||
} else if us > 1000 {
|
||||
format!("{:.2}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}µs", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_grep(files: &[fff::FileItem], index: Option<&fff::BigramFilter>, query: &str, iters: usize) {
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: usize::MAX,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let budget = ContentCacheBudget::default();
|
||||
let mut times_us = Vec::with_capacity(iters);
|
||||
|
||||
for i in 0..iters {
|
||||
let t = Instant::now();
|
||||
let result = grep_search(files, &parsed, &options, &budget, index, None, None);
|
||||
let us = t.elapsed().as_micros();
|
||||
times_us.push(us);
|
||||
|
||||
eprintln!(
|
||||
" iter {}: {} ({} matches in {} files, {}/{} searched)",
|
||||
i + 1,
|
||||
fmt_dur(us),
|
||||
result.matches.len(),
|
||||
result.files_with_matches,
|
||||
result.total_files_searched,
|
||||
result.total_files,
|
||||
);
|
||||
}
|
||||
|
||||
if times_us.len() > 1 {
|
||||
times_us.sort();
|
||||
let sum: u128 = times_us.iter().sum();
|
||||
let mean = sum / times_us.len() as u128;
|
||||
let median = times_us[times_us.len() / 2];
|
||||
let min = times_us[0];
|
||||
let max = times_us[times_us.len() - 1];
|
||||
eprintln!(
|
||||
" mean: {} median: {} min: {} max: {}",
|
||||
fmt_dur(mean),
|
||||
fmt_dur(median),
|
||||
fmt_dur(min),
|
||||
fmt_dur(max)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bigram(files: &mut [fff::FileItem]) -> fff::BigramFilter {
|
||||
let budget = ContentCacheBudget::default();
|
||||
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
|
||||
|
||||
for &i in &binary_indices {
|
||||
files[i].set_binary(true);
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let path = args
|
||||
.iter()
|
||||
.position(|a| a == "--path")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(".");
|
||||
|
||||
let query = args
|
||||
.iter()
|
||||
.position(|a| a == "--query")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("TODO");
|
||||
|
||||
let iters: usize = args
|
||||
.iter()
|
||||
.position(|a| a == "--iters")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let no_bigram = args.iter().any(|a| a == "--no-bigram");
|
||||
|
||||
let repo = std::path::PathBuf::from(path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Path not found: {}", path);
|
||||
eprintln!("Usage: bench_grep_query --path <dir> --query <text> [--iters N] [--no-bigram]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
|
||||
eprintln!("=== bench_grep_query ===");
|
||||
eprintln!("Path: {}", canonical.display());
|
||||
eprintln!("Query: \"{}\"", query);
|
||||
eprintln!("Iters: {}", iters);
|
||||
eprintln!();
|
||||
|
||||
// ── 1. Scan files ──────────────────────────────────────────────────
|
||||
eprint!("[1/3] Scanning files... ");
|
||||
let t = Instant::now();
|
||||
let mut files = fff::scan_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(
|
||||
"{} files in {:.2}s ({} non-binary)",
|
||||
files.len(),
|
||||
t.elapsed().as_secs_f64(),
|
||||
non_binary,
|
||||
);
|
||||
|
||||
if no_bigram {
|
||||
eprintln!("[2/3] Bigram index skipped (--no-bigram)");
|
||||
eprintln!(
|
||||
"\n[3/3] Running grep \"{}\" x {} iterations\n",
|
||||
query, iters
|
||||
);
|
||||
run_grep(&files, None, query, iters);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Build bigram index ──────────────────────────────────────────
|
||||
eprint!("[2/3] Bigram index... ");
|
||||
let t = Instant::now();
|
||||
let index = build_bigram(&mut files);
|
||||
eprintln!(
|
||||
"done in {:.2}s ({} cols, {:.1} MB)",
|
||||
t.elapsed().as_secs_f64(),
|
||||
index.columns_used(),
|
||||
index.heap_bytes() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
|
||||
// ── 3. Grep ───────────────────────────────────────────────────────
|
||||
eprintln!(
|
||||
"\n[3/3] Running grep \"{}\" x {} iterations\n",
|
||||
query, iters
|
||||
);
|
||||
run_grep(&files, Some(&index), query, iters);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -35,12 +35,17 @@ fn main() {
|
||||
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
|
||||
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
0,
|
||||
None,
|
||||
@@ -64,7 +69,6 @@ fn main() {
|
||||
("long_rare", "user_authentication", 100),
|
||||
("typo_resistant", "contrlr", 200),
|
||||
("path_like", "src/lib", 150),
|
||||
("single_char", "a", 300),
|
||||
("two_char", "st", 300),
|
||||
("partial_word", "test", 200),
|
||||
("deep_path", "drivers/net", 100),
|
||||
@@ -87,13 +91,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;
|
||||
@@ -31,14 +31,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
@@ -71,11 +76,22 @@ 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,
|
||||
&fff::ContentCacheBudget::zero(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
eprintln!("══════════════════════════════════════════════════════════════");
|
||||
@@ -99,7 +115,7 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
if m.file_index != current_file_idx {
|
||||
current_file_idx = m.file_index;
|
||||
let file = &result.files[m.file_index];
|
||||
eprintln!("\n ┌─ {}", file.relative_path);
|
||||
eprintln!("\n ┌─ {}", file.relative_path());
|
||||
}
|
||||
|
||||
// Truncate long lines for display
|
||||
@@ -163,15 +179,14 @@ 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);
|
||||
|
||||
eprintln!("Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(
|
||||
"Loaded {} files ({} non-binary) in {:.2}s\n",
|
||||
files.len(),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use fff_core::FileItem;
|
||||
/// Live grep benchmark profiler for fff.nvim
|
||||
///
|
||||
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
|
||||
@@ -10,7 +9,11 @@ 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::{
|
||||
BigramFilter, FileItem,
|
||||
grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query},
|
||||
types::ContentCacheBudget,
|
||||
};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -34,14 +37,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
@@ -115,29 +123,51 @@ impl BenchStats {
|
||||
struct GrepBench<'a> {
|
||||
files: &'a [FileItem],
|
||||
options: GrepSearchOptions,
|
||||
bigram_index: Option<&'a BigramFilter>,
|
||||
}
|
||||
|
||||
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,
|
||||
bigram_index: None,
|
||||
options: GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
mode,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn with_bigram(mut self, index: &'a BigramFilter) -> Self {
|
||||
self.bigram_index = Some(index);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run a single grep search, return (duration, match_count, files_searched)
|
||||
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(self.files, query, parsed, &self.options);
|
||||
let result = grep_search(
|
||||
self.files,
|
||||
&parsed,
|
||||
&self.options,
|
||||
&ContentCacheBudget::default(),
|
||||
self.bigram_index,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
(elapsed, result.matches.len(), result.total_files_searched)
|
||||
}
|
||||
@@ -159,6 +189,17 @@ impl<'a> GrepBench<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bigram(files: &mut [FileItem]) -> BigramFilter {
|
||||
let budget = ContentCacheBudget::default();
|
||||
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
|
||||
|
||||
for &i in &binary_indices {
|
||||
files[i].set_binary(true);
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
fn fmt_dur(d: Duration) -> String {
|
||||
let us = d.as_micros();
|
||||
if us > 1_000_000 {
|
||||
@@ -215,16 +256,16 @@ 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 mut files = load_files(&canonical);
|
||||
let load_time = load_start.elapsed();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
let large_files = files.iter().filter(|f| f.size > 10 * 1024 * 1024).count();
|
||||
eprintln!(
|
||||
" Loaded {} files in {:.2}s ({} non-binary, {} >10MB skipped)\n",
|
||||
@@ -236,7 +277,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 +300,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,9 +334,107 @@ fn main() {
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
eprintln!("\n[4/5] Incremental typing simulation");
|
||||
eprintln!("\n[3b/7] Building bigram index...");
|
||||
let bigram_start = Instant::now();
|
||||
let bigram_index = build_bigram(&mut files);
|
||||
eprintln!(
|
||||
" Built in {:.2}s ({} columns, {:.1} MB)\n",
|
||||
bigram_start.elapsed().as_secs_f64(),
|
||||
bigram_index.file_count(),
|
||||
bigram_index.heap_bytes() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
|
||||
eprintln!("[3c/7] Bigram-accelerated warm benchmarks (same queries, with bigram prefilter)");
|
||||
print_header();
|
||||
|
||||
let bigram_bench = GrepBench::new(&files).with_bigram(&bigram_index);
|
||||
for (name, query, iters) in &warm_queries {
|
||||
let bigram_name = format!("bg_{}", name.strip_prefix("warm_").unwrap_or(name));
|
||||
let (stats, matches, files_searched) = bigram_bench.bench_query(query, *iters);
|
||||
print_row(&bigram_name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
|
||||
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
let fuzzy_bench = GrepBench::with_mode(&files, GrepMode::Fuzzy);
|
||||
|
||||
let fuzzy_queries: Vec<(&str, &str, usize)> = vec![
|
||||
("fuzzy_exact", "mutex_lock", 15),
|
||||
("fuzzy_typo", "mutx_lock", 15),
|
||||
("fuzzy_camel", "InodeOps", 15),
|
||||
("fuzzy_abbrev", "sched_rt", 15),
|
||||
("fuzzy_short", "kfr", 15),
|
||||
("fuzzy_common", "return", 10),
|
||||
("fuzzy_define", "MODULE_LICENSE", 15),
|
||||
("fuzzy_struct", "file_operations", 15),
|
||||
("fuzzy_long", "static_int_init", 15),
|
||||
("fuzzy_path", "printk *.c", 15),
|
||||
];
|
||||
|
||||
// Warmup
|
||||
for (_, query, _) in &fuzzy_queries {
|
||||
for _ in 0..3 {
|
||||
fuzzy_bench.run_once(query);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, query, iters) in &fuzzy_queries {
|
||||
let (stats, matches, files_searched) = fuzzy_bench.bench_query(query, *iters);
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy incremental typing ────────────────────────────────────────
|
||||
eprintln!("\n[5/7] Fuzzy incremental typing simulation");
|
||||
eprintln!(" Simulates user typing character by character (fuzzy mode).\n");
|
||||
|
||||
let fuzzy_typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
vec![
|
||||
"m",
|
||||
"mu",
|
||||
"mut",
|
||||
"mute",
|
||||
"mutex",
|
||||
"mutex_",
|
||||
"mutex_l",
|
||||
"mutex_lo",
|
||||
"mutex_loc",
|
||||
"mutex_lock",
|
||||
],
|
||||
),
|
||||
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
|
||||
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
|
||||
];
|
||||
|
||||
for (name, sequence) in &fuzzy_typing_sequences {
|
||||
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
"Query", "Latency", "Match", "Files"
|
||||
);
|
||||
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
|
||||
|
||||
for prefix in sequence {
|
||||
let (elapsed, matches, files_searched) = fuzzy_bench.run_once(prefix);
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
format!("\"{}\"", prefix),
|
||||
fmt_dur(elapsed),
|
||||
matches,
|
||||
files_searched,
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[6/7] Incremental typing simulation (plain text)");
|
||||
eprintln!(" Simulates user typing character by character.\n");
|
||||
|
||||
let bench = GrepBench::new(&files);
|
||||
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
@@ -338,7 +477,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 +502,20 @@ 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,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
|
||||
@@ -384,7 +534,13 @@ fn main() {
|
||||
}
|
||||
|
||||
eprintln!("\n=== Summary ===");
|
||||
let mmap_count = files.iter().filter(|f| f.get_mmap().is_some()).count();
|
||||
let mmap_count = files
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.get_content_for_search(&fff::ContentCacheBudget::unlimited())
|
||||
.is_some()
|
||||
})
|
||||
.count();
|
||||
eprintln!(" Files with cached mmap: {}", mmap_count);
|
||||
eprintln!(" Total indexed files: {}", files.len());
|
||||
eprintln!(" Non-binary files: {}", non_binary);
|
||||
|
||||
@@ -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;
|
||||
@@ -47,14 +48,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
@@ -204,9 +210,48 @@ 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,
|
||||
&fff::ContentCacheBudget::zero(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
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,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
@@ -222,9 +267,20 @@ 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,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
@@ -292,7 +348,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")
|
||||
@@ -314,7 +370,7 @@ fn main() {
|
||||
|
||||
eprintln!("[1/5] Indexing files...");
|
||||
let files = load_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(" {} files ({} searchable)\n", files.len(), non_binary);
|
||||
|
||||
eprintln!("[2/5] Warming caches (fff mmap + OS page cache)...");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -90,13 +89,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 {
|
||||
@@ -180,16 +178,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Initialize FilePicker
|
||||
println!("Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// Wait for initial scan
|
||||
|
||||
@@ -1,50 +1,23 @@
|
||||
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};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut iteration = 0;
|
||||
if !shared_picker.wait_for_scan(timeout) {
|
||||
return Err(format!("Scan timed out after {} seconds", timeout_secs));
|
||||
}
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let is_scanning = picker.is_scan_active();
|
||||
let file_count = picker.get_files().len();
|
||||
|
||||
if iteration % 20 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] Scanning: {}, Files: {}",
|
||||
start.elapsed().as_secs_f64(),
|
||||
is_scanning,
|
||||
file_count
|
||||
);
|
||||
}
|
||||
|
||||
if !is_scanning && file_count > 0 {
|
||||
return Ok(file_count);
|
||||
}
|
||||
} else if iteration % 20 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] FilePicker is None",
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
|
||||
if start.elapsed() > timeout {
|
||||
return Err(format!("Scan timed out after {} seconds", timeout_secs));
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|e| format!("Failed to acquire read lock: {}", e))?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().len())
|
||||
} else {
|
||||
Err("FilePicker not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +25,7 @@ fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usiz
|
||||
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
.map_err(|e| format!("Failed to acquire read lock: {}", e))?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().to_vec())
|
||||
} else {
|
||||
@@ -71,20 +44,24 @@ 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));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: canonical_path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to init FilePicker");
|
||||
.expect("Failed to init FilePicker with shared state");
|
||||
|
||||
// Give background thread time to start
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
@@ -97,16 +74,16 @@ fn main() {
|
||||
|
||||
// Test queries representing different search patterns
|
||||
let test_queries = vec![
|
||||
("short_common", "mod", 5000),
|
||||
("medium_specific", "controller", 2000),
|
||||
("long_rare", "user_authentication", 1000),
|
||||
("typo_resistant", "contrlr", 2000),
|
||||
("path_like", "src/lib", 1500),
|
||||
("single_char", "a", 3000),
|
||||
("two_char", "st", 3000),
|
||||
("partial_word", "test", 2000),
|
||||
("deep_path", "drivers/net", 1000),
|
||||
("extension", ".rs", 2000),
|
||||
("short_common", "mod", 100),
|
||||
("medium_specific", "controller", 100),
|
||||
("long_rare", "user_authentication", 100),
|
||||
("typo_resistant", "contrlr", 100),
|
||||
("path_like", "src/lib", 100),
|
||||
("single_char", "a", 100),
|
||||
("two_char", "st", 100),
|
||||
("partial_word", "test", 100),
|
||||
("deep_path", "drivers/net", 100),
|
||||
("extension", ".rs", 100),
|
||||
];
|
||||
|
||||
eprintln!("Running search profiler...");
|
||||
@@ -125,13 +102,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,8 +1,7 @@
|
||||
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};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -79,16 +78,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Initialize the file picker
|
||||
println!("📁 Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// Wait for initial scan to complete
|
||||
@@ -141,7 +144,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !files.is_empty() {
|
||||
println!("Sample files:");
|
||||
for (i, file) in files.iter().take(5).enumerate() {
|
||||
println!(" {}. {}", i + 1, file.relative_path);
|
||||
println!(" {}. {}", i + 1, file.relative_path());
|
||||
}
|
||||
}
|
||||
files.len()
|
||||
@@ -199,13 +202,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,13 +2,13 @@
|
||||
#![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::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -25,11 +25,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let r = running.clone();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Clone for signal handler
|
||||
let picker_for_cleanup = Arc::clone(&shared_picker);
|
||||
let picker_for_cleanup = shared_picker.clone();
|
||||
ctrlc::set_handler(move || {
|
||||
println!("\n🛑 Received interrupt signal, shutting down...");
|
||||
if let Ok(mut guard) = picker_for_cleanup.write() {
|
||||
@@ -46,10 +46,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Initialize the file picker using shared state
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::default(),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// Get initial file count from shared state
|
||||
@@ -64,7 +68,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(
|
||||
" {}. {} ({})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
file.relative_path(),
|
||||
format_git_status(file.git_status)
|
||||
);
|
||||
}
|
||||
@@ -106,7 +110,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let newest_files = files.iter().rev().take(added.min(3));
|
||||
for file in newest_files {
|
||||
println!(" ➕ {}", file.relative_path);
|
||||
println!(" ➕ {}", file.relative_path());
|
||||
}
|
||||
} else {
|
||||
let removed = last_count - current_count;
|
||||
@@ -152,13 +156,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 {
|
||||
@@ -183,7 +186,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(
|
||||
" {}. {} (score: {})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
file.relative_path(),
|
||||
score.total
|
||||
);
|
||||
}
|
||||
@@ -198,5 +201,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -24,14 +24,3 @@ impl<T> IntoLuaResult<T> for Result<T, CoreError> {
|
||||
self.map_err(to_lua_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for Result<T, PoisonError> to convert to Result<T, CoreError>
|
||||
pub trait IntoCoreError<T> {
|
||||
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError>;
|
||||
}
|
||||
|
||||
impl<T, G> IntoCoreError<T> for Result<T, std::sync::PoisonError<G>> {
|
||||
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError> {
|
||||
self.map_err(|_| err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+167
-163
@@ -1,21 +1,22 @@
|
||||
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 error::IntoLuaResult;
|
||||
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::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use path_shortening::PathShortenStrategy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
mod error;
|
||||
mod hex_dump;
|
||||
mod log;
|
||||
mod lua_types;
|
||||
mod path_shortening;
|
||||
@@ -25,74 +26,67 @@ static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
// the global state for neovim lives here for efficiency
|
||||
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
|
||||
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(SharedPicker::default);
|
||||
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(SharedFrecency::default);
|
||||
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(SharedQueryTracker::default);
|
||||
|
||||
pub fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let mut frecency = FRECENCY.write().into_lua_result()?;
|
||||
if frecency.is_some() {
|
||||
*frecency = None;
|
||||
}
|
||||
*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);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
// Spawn background GC to purge stale entries without blocking startup
|
||||
let _ = FRECENCY.spawn_gc(frecency_db_path, use_unsafe_no_lock);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER.write().into_lua_result()?;
|
||||
if query_tracker.is_some() {
|
||||
*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)
|
||||
}
|
||||
|
||||
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let mut frecency = FRECENCY.write().into_lua_result()?;
|
||||
*frecency = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let mut query_tracker = QUERY_TRACKER.write().into_lua_result()?;
|
||||
*query_tracker = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
{
|
||||
let guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let guard = FILE_PICKER.read().into_lua_result()?;
|
||||
if guard.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
false,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
FILE_PICKER.clone(),
|
||||
FRECENCY.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path,
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.into_lua_result()?;
|
||||
|
||||
@@ -104,9 +98,7 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
// 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)?;
|
||||
let mut guard = FILE_PICKER.write()?;
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
@@ -119,10 +111,14 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string_lossy().to_string(),
|
||||
false,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
FILE_PICKER.clone(),
|
||||
FRECENCY.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@@ -137,7 +133,7 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&path).map_err(|e| {
|
||||
let canonical_path = fff::path_utils::canonicalize(&path).map_err(|e| {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
@@ -164,10 +160,7 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -199,10 +192,7 @@ pub fn fuzzy_search_files(
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
@@ -210,26 +200,13 @@ 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().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,
|
||||
@@ -238,19 +215,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 {
|
||||
@@ -260,6 +236,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.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)
|
||||
}
|
||||
|
||||
@@ -286,23 +290,19 @@ pub fn live_grep(
|
||||
Option<u64>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let parsed = fff_core::grep::parse_grep_query(&query);
|
||||
|
||||
let 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),
|
||||
@@ -310,22 +310,71 @@ 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 = picker.grep(&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", 0i32)?;
|
||||
item.set("modification_frecency_score", 0i32)?;
|
||||
item.set("total_frecency_score", 0i32)?;
|
||||
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);
|
||||
|
||||
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
|
||||
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let frecency_guard = FRECENCY.read().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -335,18 +384,12 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
drop(frecency_guard);
|
||||
|
||||
// Quick lock to update single file's frecency score in picker
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let frecency_guard = FRECENCY.read().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -358,10 +401,7 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -375,10 +415,7 @@ pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
|
||||
}
|
||||
|
||||
pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -387,10 +424,7 @@ pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -399,22 +433,16 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
|
||||
FILE_PICKER.refresh_git_status(&FRECENCY).into_lua_result()
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let frecency_guard = FRECENCY.read().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
@@ -426,10 +454,7 @@ pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
@@ -440,10 +465,7 @@ pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
if let Some(picker) = file_picker.take() {
|
||||
drop(picker);
|
||||
::tracing::info!("FilePicker cleanup completed");
|
||||
@@ -461,10 +483,7 @@ pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
|
||||
// Get the project path before spawning thread
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -472,7 +491,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");
|
||||
@@ -481,9 +500,10 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
let query_tracker = QUERY_TRACKER.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
if let Ok(mut guard) = query_tracker.write()
|
||||
&& let Some(tracker) = guard.as_mut()
|
||||
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -500,20 +520,14 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
|
||||
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let query_tracker = QUERY_TRACKER.read().into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -525,19 +539,17 @@ pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>>
|
||||
|
||||
pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
let query_tracker = QUERY_TRACKER.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
if let Ok(mut guard) = query_tracker.write()
|
||||
&& let Some(ref mut tracker) = *guard
|
||||
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -553,20 +565,14 @@ pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
|
||||
|
||||
pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let query_tracker = QUERY_TRACKER.read().into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -582,10 +588,7 @@ pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool
|
||||
// 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 file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -818,6 +821,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>,
|
||||
}
|
||||
@@ -41,9 +35,9 @@ impl IntoLua for LuaPosition {
|
||||
|
||||
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("path", item.path.to_string_lossy().to_string())?;
|
||||
table.set("relative_path", item.relative_path.clone())?;
|
||||
table.set("name", item.file_name.clone())?;
|
||||
table.set("path", item.path_str())?;
|
||||
table.set("relative_path", item.relative_path())?;
|
||||
table.set("name", item.file_name())?;
|
||||
table.set("size", item.size)?;
|
||||
table.set("modified", item.modified)?;
|
||||
table.set("access_frecency_score", item.access_frecency_score)?;
|
||||
@@ -51,9 +45,9 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
"modification_frecency_score",
|
||||
item.modification_frecency_score,
|
||||
)?;
|
||||
table.set("total_frecency_score", item.total_frecency_score)?;
|
||||
table.set("total_frecency_score", item.total_frecency_score())?;
|
||||
table.set("git_status", format_git_status(item.git_status))?;
|
||||
table.set("is_binary", item.is_binary)?;
|
||||
table.set("is_binary", item.is_binary())?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
@@ -128,14 +122,14 @@ impl IntoLua for GrepResultLua<'_> {
|
||||
|
||||
// File metadata from the deduplicated files vec
|
||||
let file = self.inner.files[m.file_index];
|
||||
item.set("path", file.path.to_string_lossy().to_string())?;
|
||||
item.set("relative_path", file.relative_path.as_str())?;
|
||||
item.set("name", file.file_name.as_str())?;
|
||||
item.set("is_binary", file.is_binary)?;
|
||||
item.set("path", file.path_str())?;
|
||||
item.set("relative_path", file.relative_path())?;
|
||||
item.set("name", file.file_name())?;
|
||||
item.set("is_binary", file.is_binary())?;
|
||||
item.set("git_status", format_git_status(file.git_status))?;
|
||||
item.set("size", file.size)?;
|
||||
item.set("modified", file.modified)?;
|
||||
item.set("total_frecency_score", file.total_frecency_score)?;
|
||||
item.set("total_frecency_score", file.total_frecency_score())?;
|
||||
item.set("access_frecency_score", file.access_frecency_score)?;
|
||||
item.set(
|
||||
"modification_frecency_score",
|
||||
|
||||
@@ -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.5.2"
|
||||
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"
|
||||
@@ -11,7 +14,6 @@ default = []
|
||||
zlob = ["dep:zlob"]
|
||||
|
||||
[dependencies]
|
||||
smallvec = { workspace = true }
|
||||
zlob = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Constraint types that can be extracted from a query
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Constraint<'a> {
|
||||
@@ -22,6 +20,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),
|
||||
|
||||
@@ -41,5 +43,5 @@ pub enum GitStatusFilter {
|
||||
Unmodified,
|
||||
}
|
||||
|
||||
/// Stack-allocated buffer for text parts (up to 16 parts without heap allocation)
|
||||
pub(crate) type TextPartsBuffer<'a> = SmallVec<[&'a str; 16]>;
|
||||
/// Buffer for text parts during query parsing.
|
||||
pub(crate) type TextPartsBuffer<'a> = Vec<&'a str>;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Fast, zero-allocation query parser for file search
|
||||
//! Fast query parser for file search
|
||||
//!
|
||||
//! This parser takes a search query and extracts structured constraints
|
||||
//! while preserving text for fuzzy matching. Designed for maximum performance:
|
||||
//! - Zero allocations for queries with ≤8 constraints (SmallVec)
|
||||
//! - Single-pass parsing with minimal branching
|
||||
//! - Stack-allocated string buffers
|
||||
//!
|
||||
@@ -13,12 +12,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 +26,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,16 +45,12 @@ 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};
|
||||
|
||||
// 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]>;
|
||||
pub type ConstraintVec<'a> = Vec<Constraint<'a>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -64,32 +60,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 +101,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 +110,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 +122,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 +135,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 +146,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 +157,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 +168,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 {
|
||||
@@ -224,21 +208,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_heap_allocation_for_small_queries() {
|
||||
fn test_small_constraint_count() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("*.rs *.toml !test")
|
||||
.expect("Should parse multi-token");
|
||||
// SmallVec should not have spilled to heap
|
||||
assert!(!result.constraints.spilled());
|
||||
let result = parser.parse("*.rs *.toml !test");
|
||||
assert_eq!(result.constraints.len(), 3);
|
||||
}
|
||||
|
||||
#[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,341 @@ mod tests {
|
||||
other => panic!("Expected Not constraint, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
#[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(vec!["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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "grep-searcher"
|
||||
version = "0.1.16"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
|
||||
grep-matcher = { workspace = true }
|
||||
memchr = "2.6.3"
|
||||
@@ -1,212 +0,0 @@
|
||||
use grep_matcher::{LineMatchKind, Matcher};
|
||||
|
||||
use crate::{
|
||||
lines::{self, LineStep},
|
||||
searcher::{Config, Range, Searcher},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Core<'s, M: 's, S> {
|
||||
config: &'s Config,
|
||||
matcher: M,
|
||||
searcher: &'s Searcher,
|
||||
sink: S,
|
||||
pos: usize,
|
||||
absolute_byte_offset: u64,
|
||||
line_number: Option<u64>,
|
||||
last_line_counted: usize,
|
||||
last_line_visited: usize,
|
||||
}
|
||||
|
||||
impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
pub(crate) fn new(searcher: &'s Searcher, matcher: M, sink: S) -> Core<'s, M, S> {
|
||||
let line_number = if searcher.config.line_number {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Core {
|
||||
config: &searcher.config,
|
||||
matcher,
|
||||
searcher,
|
||||
sink,
|
||||
pos: 0,
|
||||
absolute_byte_offset: 0,
|
||||
line_number,
|
||||
last_line_counted: 0,
|
||||
last_line_visited: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pos(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
pub(crate) fn set_pos(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
pub(crate) fn matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.sink_matched(buf, range)
|
||||
}
|
||||
|
||||
pub(crate) fn find(&mut self, slice: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
match self.matcher.find(slice) {
|
||||
Err(err) => Err(S::Error::error_message(err)),
|
||||
Ok(m) => Ok(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn shortest_match(&mut self, slice: &[u8]) -> Result<Option<usize>, S::Error> {
|
||||
match self.matcher.shortest_match(slice) {
|
||||
Err(err) => Err(S::Error::error_message(err)),
|
||||
Ok(m) => Ok(m),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self) -> Result<bool, S::Error> {
|
||||
self.sink.begin(self.searcher)
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self, byte_count: u64) -> Result<(), S::Error> {
|
||||
self.sink.finish(self.searcher, &SinkFinish { byte_count })
|
||||
}
|
||||
|
||||
pub(crate) fn match_by_line(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
if self.is_line_by_line_fast() {
|
||||
self.match_by_line_fast(buf)
|
||||
} else {
|
||||
self.match_by_line_slow(buf)
|
||||
}
|
||||
}
|
||||
|
||||
fn match_by_line_slow(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
|
||||
let range = Range::new(self.pos(), buf.len());
|
||||
let mut stepper =
|
||||
LineStep::new(self.config.line_term.as_byte(), range.start(), range.end());
|
||||
while let Some(line) = stepper.next_match(buf) {
|
||||
let matched = {
|
||||
let slice = lines::without_terminator(&buf[line], self.config.line_term);
|
||||
self.shortest_match(slice)?.is_some()
|
||||
};
|
||||
self.set_pos(line.end());
|
||||
if matched && !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn match_by_line_fast(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
while !buf[self.pos()..].is_empty() {
|
||||
if let Some(line) = self.find_by_line_fast(buf)? {
|
||||
self.set_pos(line.end());
|
||||
if !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.set_pos(buf.len());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn find_by_line_fast(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
debug_assert!(self.is_line_by_line_fast());
|
||||
|
||||
let mut pos = self.pos();
|
||||
while !buf[pos..].is_empty() {
|
||||
match self.matcher.find_candidate_line(&buf[pos..]) {
|
||||
Err(err) => return Err(S::Error::error_message(err)),
|
||||
Ok(None) => return Ok(None),
|
||||
Ok(Some(LineMatchKind::Confirmed(i))) => {
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(i).offset(pos),
|
||||
);
|
||||
if line.start() == buf.len() {
|
||||
pos = buf.len();
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(line));
|
||||
}
|
||||
Ok(Some(LineMatchKind::Candidate(i))) => {
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(i).offset(pos),
|
||||
);
|
||||
let slice = lines::without_terminator(&buf[line], self.config.line_term);
|
||||
if self
|
||||
.matcher
|
||||
.is_match(slice)
|
||||
.map_err(S::Error::error_message)?
|
||||
{
|
||||
return Ok(Some(line));
|
||||
}
|
||||
pos = line.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn sink_matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.count_lines(buf, range.start());
|
||||
let offset = self.absolute_byte_offset + range.start() as u64;
|
||||
let linebuf = &buf[*range];
|
||||
let keepgoing = self.sink.matched(
|
||||
self.searcher,
|
||||
&SinkMatch {
|
||||
bytes: linebuf,
|
||||
absolute_byte_offset: offset,
|
||||
line_number: self.line_number,
|
||||
buffer: buf,
|
||||
bytes_range_in_buffer: range.start()..range.end(),
|
||||
},
|
||||
)?;
|
||||
if !keepgoing {
|
||||
return Ok(false);
|
||||
}
|
||||
self.last_line_visited = range.end();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn count_lines(&mut self, buf: &[u8], upto: usize) {
|
||||
if let Some(ref mut line_number) = self.line_number {
|
||||
if self.last_line_counted >= upto {
|
||||
return;
|
||||
}
|
||||
let slice = &buf[self.last_line_counted..upto];
|
||||
let count = lines::count(slice, self.config.line_term.as_byte());
|
||||
*line_number += count;
|
||||
self.last_line_counted = upto;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_line_by_line_fast(&self) -> bool {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
if let Some(line_term) = self.matcher.line_terminator() {
|
||||
if line_term.as_byte() == b'\x00' {
|
||||
return false;
|
||||
}
|
||||
if line_term == self.config.line_term {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(non_matching) = self.matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.config.line_term.as_byte())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
+82
-74
@@ -1,52 +1,68 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 06
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 April 08
|
||||
|
||||
==============================================================================
|
||||
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,19 +307,21 @@ KEY FEATURES ~
|
||||
AVAILABLE METHODS
|
||||
|
||||
>lua
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').find_files() -- Find files in current repository
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file list
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
<
|
||||
|
||||
just jump to the definition and see what other APIs are exposed we have a
|
||||
plenty
|
||||
|
||||
|
||||
COMMANDS
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
@@ -313,13 +330,6 @@ FFF.nvim provides several commands for interacting with the file picker:
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
|
||||
MULTILINE PASTE SUPPORT
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining
|
||||
all lines into a single search query. This is particularly useful when copying
|
||||
file paths from terminal output.
|
||||
|
||||
|
||||
DEBUG MODE
|
||||
|
||||
Toggle scoring information display:
|
||||
@@ -387,6 +397,34 @@ When only one mode is configured, the mode indicator is hidden completely and
|
||||
the cycle keybind does nothing.
|
||||
|
||||
|
||||
CONSTRAINTS
|
||||
|
||||
There are a number of constraints you can use to refine your search in both
|
||||
grep and file search mode:
|
||||
|
||||
- `git:modified` - show only modified files (one of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`)
|
||||
- `test/` - any deeply nested children of any test/ dir
|
||||
- `!something` - exclude results matching something
|
||||
- `!test/`, `!git:modified` - combining with any other constraint works as negation
|
||||
- `./**/*.{rs,lua}` - any valid glob expression via the fastest globbing library <https://github.com/dmtrKovalenko/zlob>
|
||||
|
||||
For grep only:
|
||||
|
||||
- `*.md`, `*.{c,h}` - extension filtering
|
||||
- `src/main.rs` - grep in a single file
|
||||
|
||||
In addition to that, all constraints can be combined together like:
|
||||
|
||||
>
|
||||
git:modified src/**/*.rs !src/**/mod.rs user controller
|
||||
<
|
||||
|
||||
This will find all the files that qualify the constraints and:
|
||||
|
||||
- match **both** user and controller (for file mode)
|
||||
- match "user controller" (for grep mode)
|
||||
|
||||
|
||||
CROSS-MODE SUGGESTIONS
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search
|
||||
@@ -496,47 +534,17 @@ VIEWING LOGS
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
>vim
|
||||
>
|
||||
:FFFOpenLog
|
||||
<
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
|
||||
location).
|
||||
|
||||
==============================================================================
|
||||
1. Links *fff.nvim-links*
|
||||
|
||||
COMMON ISSUES
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it’s properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
|
||||
DEBUG MODE
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
1. *Chart showing the superiority of fff.nvim over builtin claude code tools*: ./chart.png
|
||||
|
||||
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1767744144,
|
||||
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
|
||||
"lastModified": 1773857772,
|
||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
|
||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -35,11 +35,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"lastModified": 1773628058,
|
||||
"narHash": "sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"rev": "f8573b9c935cfaa162dd62cc9e75ae2db86f85df",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -64,11 +64,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770865833,
|
||||
"narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=",
|
||||
"lastModified": 1773803479,
|
||||
"narHash": "sha256-GD6i1F2vrSxbsmbS92+8+x3DbHOJ+yrS78Pm4xigW4M=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85",
|
||||
"rev": "f17186f52e82ec5cf40920b58eac63b78692ac7c",
|
||||
"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
|
||||
|
||||
+2
-2
@@ -88,11 +88,11 @@ M.ensure_initialized = function()
|
||||
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
|
||||
|
||||
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
|
||||
if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end
|
||||
if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end
|
||||
|
||||
ok, result = pcall(fuzzy.init_file_picker, config.base_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
|
||||
vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR)
|
||||
return fuzzy
|
||||
end
|
||||
|
||||
|
||||
+25
-16
@@ -1,19 +1,10 @@
|
||||
local M = {}
|
||||
local system = require('fff.utils.system')
|
||||
local fs_utils = require('fff.utils.fs')
|
||||
local fff_version = require('fff.utils.version')
|
||||
|
||||
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
|
||||
|
||||
local function get_current_version(plugin_dir, callback)
|
||||
vim.system({ 'git', 'rev-parse', '--short', 'HEAD' }, { cwd = plugin_dir }, function(result)
|
||||
if result.code ~= 0 or not result.stdout or result.stdout == '' then
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
callback(result.stdout:gsub('%s+', ''))
|
||||
end)
|
||||
end
|
||||
|
||||
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
|
||||
|
||||
local function get_binary_path(plugin_dir)
|
||||
@@ -114,7 +105,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
|
||||
@@ -168,20 +159,38 @@ function M.ensure_downloaded(opts, callback)
|
||||
return
|
||||
end
|
||||
|
||||
local function on_version(target_version)
|
||||
if not target_version then
|
||||
local function on_release_tag(release_tag)
|
||||
if not release_tag then
|
||||
callback(false, 'Could not determine target version')
|
||||
return
|
||||
end
|
||||
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
download_from_github(target_version, binary_path, opts, callback)
|
||||
download_from_github(release_tag, binary_path, opts, callback)
|
||||
end
|
||||
|
||||
if opts.version then
|
||||
on_version(opts.version)
|
||||
on_release_tag(opts.version)
|
||||
else
|
||||
get_current_version(plugin_dir, on_version)
|
||||
-- plugin_dir is <repo>/lua; parent is the repo root
|
||||
local repo_root = vim.fn.fnamemodify(plugin_dir, ':h')
|
||||
|
||||
-- 1. Try reading the CI-created tag on HEAD (no version computation)
|
||||
local tag = fff_version.current_release_tag(repo_root)
|
||||
if tag then
|
||||
on_release_tag(tag)
|
||||
return
|
||||
end
|
||||
|
||||
-- 2. No local tag — construct the nightly version (bumps patch so
|
||||
-- the prerelease is higher than Cargo.toml base in semver)
|
||||
local info, err = fff_version.resolve(repo_root)
|
||||
if info then
|
||||
on_release_tag(info.release_tag)
|
||||
return
|
||||
end
|
||||
|
||||
callback(false, err or 'Could not determine target version')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function M.scan_files()
|
||||
|
||||
local ok, result = pcall(fuzzy.scan_files)
|
||||
if not ok then
|
||||
vim.notify('Failed to trigger file scan: ' .. result, vim.log.levels.ERROR)
|
||||
vim.notify('Failed to trigger file scan: ' .. tostring(result), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -121,7 +121,7 @@ function M.track_access(file_path)
|
||||
if not M.state.initialized then return end
|
||||
|
||||
local ok, result = pcall(fuzzy.track_access, file_path)
|
||||
if not ok then vim.notify('Failed to record file access: ' .. result, vim.log.levels.WARN) end
|
||||
if not ok then vim.notify('Failed to record file access: ' .. tostring(result), vim.log.levels.WARN) end
|
||||
end
|
||||
|
||||
--- Get file content for preview
|
||||
@@ -156,7 +156,7 @@ function M.get_scan_progress()
|
||||
|
||||
local ok, result = pcall(fuzzy.get_scan_progress)
|
||||
if not ok then
|
||||
vim.notify('Failed to get scan progress: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to get scan progress: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return { scanned_files_count = 0, is_scanning = false }
|
||||
end
|
||||
|
||||
@@ -170,7 +170,7 @@ function M.refresh_git_status()
|
||||
|
||||
local ok, result = pcall(fuzzy.refresh_git_status)
|
||||
if not ok then
|
||||
vim.notify('Failed to refresh git status: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to refresh git status: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return {}
|
||||
end
|
||||
|
||||
@@ -185,7 +185,7 @@ function M.stop_background_monitor()
|
||||
|
||||
local ok, result = pcall(fuzzy.stop_background_monitor)
|
||||
if not ok then
|
||||
vim.notify('Failed to stop background monitor: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to stop background monitor: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return false
|
||||
end
|
||||
return result
|
||||
@@ -199,7 +199,7 @@ function M.wait_for_initial_scan(timeout_ms)
|
||||
|
||||
local ok, result = pcall(fuzzy.wait_for_initial_scan, timeout_ms)
|
||||
if not ok then
|
||||
vim.notify('Failed to wait for initial scan: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to wait for initial scan: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return false
|
||||
end
|
||||
return result
|
||||
|
||||
+139
-50
@@ -2,9 +2,32 @@ 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 = {}
|
||||
|
||||
-- Preview buffers are scratch buffers. Detect the file's language and attach
|
||||
-- highlighting directly, but keep buffer filetype empty to avoid ftplugin and
|
||||
-- LSP side effects that are meant for real editing buffers.
|
||||
local function attach_preview_highlighter(bufnr, filetype)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
pcall(vim.treesitter.stop, bufnr)
|
||||
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
||||
|
||||
if not filetype or filetype == '' then return end
|
||||
|
||||
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, filetype)
|
||||
if not lang_ok or not lang then lang = filetype end
|
||||
|
||||
if pcall(vim.treesitter.language.add, lang) then
|
||||
pcall(vim.treesitter.start, bufnr, lang)
|
||||
else
|
||||
vim.api.nvim_set_option_value('syntax', filetype, { buf = bufnr })
|
||||
end
|
||||
end
|
||||
|
||||
local function set_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
@@ -273,7 +296,7 @@ local function link_buffer_content(source_bufnr, target_bufnr)
|
||||
set_buffer_lines(target_bufnr, lines)
|
||||
|
||||
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
|
||||
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
|
||||
if source_ft ~= '' then attach_preview_highlighter(target_bufnr, source_ft) end
|
||||
|
||||
M.state.has_more_content = false
|
||||
M.state.total_file_lines = #lines
|
||||
@@ -301,6 +324,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
|
||||
@@ -529,7 +554,7 @@ function M.preview_file(file_path, bufnr)
|
||||
set_buffer_lines(bufnr, content)
|
||||
|
||||
local file_config = M.get_file_config(file_path)
|
||||
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
|
||||
attach_preview_highlighter(bufnr, info.filetype)
|
||||
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 })
|
||||
@@ -554,7 +579,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 +649,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 })
|
||||
attach_preview_highlighter(bufnr, 'text')
|
||||
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 +719,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 +753,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 +817,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
|
||||
@@ -770,11 +860,8 @@ function M.clear_buffer(bufnr)
|
||||
cleanup_file_operation()
|
||||
M.clear_preview_visual_state(bufnr)
|
||||
|
||||
pcall(vim.treesitter.stop, bufnr)
|
||||
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
||||
attach_preview_highlighter(bufnr, '')
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
|
||||
set_buffer_lines(bufnr, {})
|
||||
@@ -797,6 +884,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
|
||||
|
||||
@@ -225,7 +225,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
|
||||
-- 9. Query match
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1, true)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user