Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56450a0f81 | |||
| 4b54106da1 | |||
| e8dd50ce5a | |||
| 51d0583145 | |||
| bb4f337a78 | |||
| 4c5c92ac38 | |||
| 2f1e51e262 | |||
| 52b1e86b63 | |||
| 2e2a7a0ab9 | |||
| b94f90f923 | |||
| 362ba12517 | |||
| f401061fa6 | |||
| 3a52fc7c76 | |||
| c262af6923 | |||
| f73106cece | |||
| 24a4a7ae75 | |||
| 1bcbce2bc6 | |||
| 573a783d2f | |||
| bcd3c76285 | |||
| ca7bf03cfc | |||
| 37bda06c0c | |||
| 287d7b75a9 | |||
| 0f5ead1ab5 | |||
| 84ba575a3f | |||
| bca71efb32 | |||
| b23a0308eb | |||
| 2a03538e11 | |||
| e00b41df50 | |||
| 4b13a91aa5 | |||
| 5b0098a072 | |||
| 1b79eecb58 | |||
| 71c33e63b2 | |||
| acd2f0ce2f | |||
| 8298260c64 | |||
| 24d71a468a | |||
| a677d645fd | |||
| a24cf55c2b | |||
| 464f9d8b85 | |||
| 743ab60f48 | |||
| 4daf8bbc50 | |||
| 2201faee5f | |||
| 7bad9f2484 | |||
| 91545f832c | |||
| 8f23da638b | |||
| cfea91ce88 | |||
| cc9ce2501a | |||
| 2465c2cad6 | |||
| 59702f3c2e | |||
| e2ce56ae51 | |||
| ed4c60833b | |||
| cebacb3c35 | |||
| 689e31284a | |||
| c450a8d346 | |||
| 77881a1c92 | |||
| 7de55be63d | |||
| 2606dce6bc | |||
| 8912a1abdb | |||
| d25ea7562b | |||
| ea1f9802d7 | |||
| 07e90e5898 | |||
| 0fd47ab72d | |||
| 7979004d77 | |||
| 210bc9e1c7 | |||
| 205f9d6db9 | |||
| 406bf2531c | |||
| 42a12051d8 | |||
| 477a8a2c96 | |||
| 9a6ee3d18a | |||
| 29585a4aa3 | |||
| 1c2c0633cd | |||
| 5158ba64b8 |
@@ -30,11 +30,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
@@ -96,21 +96,19 @@ jobs:
|
||||
|
||||
- name: Run bun tests
|
||||
shell: bash
|
||||
if: ${{ matrix.os == 'macos-latest' }}
|
||||
if: ${{ matrix.os != 'windows-latest' }}
|
||||
run: make test-bun
|
||||
|
||||
- name: Install Node.js
|
||||
if: ${{ matrix.os != 'ubuntu-latest' }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "25"
|
||||
|
||||
- name: Install node deps
|
||||
if: ${{ matrix.os != 'ubuntu-latest' }}
|
||||
- name: Install node dependencies
|
||||
shell: bash
|
||||
run: npm install
|
||||
run: cd packages/fff-node && npm install
|
||||
|
||||
- name: Run node tests
|
||||
if: ${{ matrix.os != 'ubuntu-latest' }}
|
||||
shell: bash
|
||||
run: make test-node
|
||||
|
||||
@@ -20,50 +20,52 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
## Linux builds (using cargo-zigbuild)
|
||||
# Glibc 2.17 (RHEL 7, CentOS 7 compatible)
|
||||
# Glibc 2.31 (Ubuntu 20.04, Debian 11, RHEL 9).
|
||||
# Rust 1.91+ requires glibc >= 2.31 for std::sys::random::getrandom,
|
||||
# copy_file_range, and statx; earlier targets (2.17) no longer link.
|
||||
- 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/libfff_nvim.so
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/ci/libfff_nvim.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_nvim.so
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/ci/libfff_nvim.so
|
||||
ext: so
|
||||
# Musl (statically linked)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
|
||||
artifact_name: target/x86_64-unknown-linux-musl/ci/libfff_nvim.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
artifact_name: target/aarch64-unknown-linux-musl/ci/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
artifact_name: target/aarch64-linux-android/ci/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
|
||||
artifact_name: target/x86_64-apple-darwin/ci/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
|
||||
artifact_name: target/aarch64-apple-darwin/ci/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
|
||||
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_nvim.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
|
||||
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_nvim.dll
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
@@ -77,7 +79,7 @@ jobs:
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
@@ -86,7 +88,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
@@ -100,13 +102,13 @@ jobs:
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -117,7 +119,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -137,27 +139,27 @@ jobs:
|
||||
## Linux builds
|
||||
- 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/libfff_c.so
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/ci/libfff_c.so
|
||||
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
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/ci/libfff_c.so
|
||||
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
|
||||
artifact_name: target/x86_64-unknown-linux-musl/ci/libfff_c.so
|
||||
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
|
||||
artifact_name: target/aarch64-unknown-linux-musl/ci/libfff_c.so
|
||||
npm_package: fff-bin-linux-arm64-musl
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
@@ -165,20 +167,20 @@ jobs:
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_c.so
|
||||
artifact_name: target/aarch64-linux-android/ci/libfff_c.so
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
|
||||
artifact_name: target/x86_64-apple-darwin/ci/libfff_c.dylib
|
||||
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
|
||||
artifact_name: target/aarch64-apple-darwin/ci/libfff_c.dylib
|
||||
npm_package: fff-bin-darwin-arm64
|
||||
lib_filename: libfff_c.dylib
|
||||
ext: dylib
|
||||
@@ -186,13 +188,13 @@ jobs:
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_c.dll
|
||||
artifact_name: target/x86_64-pc-windows-msvc/ci/fff_c.dll
|
||||
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
|
||||
artifact_name: target/aarch64-pc-windows-msvc/ci/fff_c.dll
|
||||
npm_package: fff-bin-win32-arm64
|
||||
lib_filename: fff_c.dll
|
||||
ext: dll
|
||||
@@ -208,7 +210,7 @@ jobs:
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
@@ -217,7 +219,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
cargo zigbuild --profile ci --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
@@ -230,13 +232,13 @@ jobs:
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -247,7 +249,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Prepare npm package
|
||||
@@ -281,34 +283,34 @@ jobs:
|
||||
## 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
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/ci/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
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.31
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/ci/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/fff-mcp
|
||||
artifact_name: target/x86_64-unknown-linux-musl/ci/fff-mcp
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/fff-mcp
|
||||
artifact_name: target/aarch64-unknown-linux-musl/ci/fff-mcp
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/fff-mcp
|
||||
artifact_name: target/x86_64-apple-darwin/ci/fff-mcp
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/fff-mcp
|
||||
artifact_name: target/aarch64-apple-darwin/ci/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
|
||||
artifact_name: target/x86_64-pc-windows-msvc/ci/fff-mcp.exe
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
|
||||
artifact_name: target/aarch64-pc-windows-msvc/ci/fff-mcp.exe
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -321,7 +323,7 @@ jobs:
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
@@ -330,13 +332,13 @@ jobs:
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-mcp --features zlob
|
||||
cargo zigbuild --profile ci --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
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
@@ -347,7 +349,7 @@ jobs:
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cargo build --profile ci --target ${{ matrix.target }} -p fff-mcp --features zlob
|
||||
cp "${{ matrix.artifact_name }}" "fff-mcp-${{ matrix.target }}.exe"
|
||||
|
||||
- name: Upload artifact
|
||||
@@ -571,3 +573,16 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-node (may already exist)"
|
||||
|
||||
- name: Publish pi-fff package
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.npm_tag }}"
|
||||
|
||||
echo "Publishing @ff-labs/pi-fff@${VERSION} with tag ${TAG}..."
|
||||
make set-npm-version PKG=packages/pi-fff VERSION="$VERSION"
|
||||
|
||||
cd packages/pi-fff
|
||||
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/pi-fff (may already exist)"
|
||||
|
||||
@@ -17,8 +17,12 @@ jobs:
|
||||
name: Test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
# Guard against deadlocks in the shared-picker / watcher teardown
|
||||
# path: a stuck test would otherwise consume a full 6h CI slot.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -26,7 +30,7 @@ jobs:
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
@@ -39,6 +43,58 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cargo test --features zlob --workspace --exclude fff-nvim
|
||||
|
||||
stress-test:
|
||||
name: Stress Test (Watcher + Git)
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
# Keep going after one OS fails so we can see whether a bug
|
||||
# reproduces everywhere or is platform-specific.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
# Long-running; don't let a stuck watcher thread burn a full CI
|
||||
# timeout. Two scenarios should finish well under this limit.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
|
||||
with:
|
||||
cache: true
|
||||
cache-on-failure: true
|
||||
cache-key: "v1-rust-stress-${{ matrix.os }}"
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Stress test (seeded / deterministic)
|
||||
shell: bash
|
||||
run: make test-stress-seeded
|
||||
env:
|
||||
FFF_STRESS_CASES: "3"
|
||||
FFF_STRESS_MIN_OPS: "30"
|
||||
FFF_STRESS_MAX_OPS: "50"
|
||||
|
||||
- name: Stress test (random / fuzzy)
|
||||
shell: bash
|
||||
run: make test-stress-random
|
||||
env:
|
||||
FFF_STRESS_CASES: "5"
|
||||
FFF_STRESS_MIN_OPS: "30"
|
||||
FFF_STRESS_MAX_OPS: "60"
|
||||
|
||||
- name: Upload proptest regressions on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: proptest-regressions-${{ matrix.os }}
|
||||
path: crates/fff-core/tests/fuzz_git_watcher_stress.proptest-regressions
|
||||
if-no-files-found: ignore
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
runs-on: ubuntu-latest
|
||||
@@ -62,7 +118,7 @@ jobs:
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
version: 0.16.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
|
||||
@@ -15,6 +15,7 @@ result
|
||||
# all the perf like utility files
|
||||
*.data
|
||||
node_modules/
|
||||
crates/fff-notify-debouncer-full/
|
||||
|
||||
dist/
|
||||
scripts/benchmark-results/
|
||||
@@ -23,3 +24,6 @@ scripts/benchmark-results/
|
||||
*.dylib
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# Instruments traces
|
||||
*.trace/
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# To Clankers
|
||||
|
||||
This repository contains **FFF.nvim (Fast File Finder)**, a high-performance file picker for Neovim inspired by blink.cmp's fuzzy matching technology. It's NOT a completion plugin, but rather a standalone file finder with advanced fuzzy search and frecency scoring. The project aims to be the drop-in replacement for telescope, fzf-lua, snacks.picker and similar plugins, focusing on speed, accuracy search and usability features.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Always prefer Makefile commands listed to the cargo/bun/node if possible.
|
||||
|
||||
### Building
|
||||
|
||||
- `make build` - build everything
|
||||
|
||||
### Testing and Development Tools
|
||||
|
||||
This project does not have a traditional test suite. Testing is done through:
|
||||
|
||||
- Create e2e local test file for Neovim: Load any Lua test file with `nvim -l <test_file>`
|
||||
- Write inline rust unit tests for any functionality that is standalone and scoped within a single function
|
||||
|
||||
### Code Quality
|
||||
|
||||
- `make lint` - Rust linting and code analysis
|
||||
- `make format` - Format all code
|
||||
- `make test` - Run unit tests (limited coverage, primarily integration testing)
|
||||
|
||||
When doing code make sure to REDUCE SIZE OF COMMENTS. This is very important. Every comment should be concise 1-2 liner maximum 4 lines if describes really extensive and unnatural concept.
|
||||
|
||||
### Important coding rules
|
||||
|
||||
- Do not add doc comments to the private structs and functions.
|
||||
- Do not make public structs if something can be private
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything that is performance critical happens in rust world, everything that is neovim specific happens in the lua code.
|
||||
|
||||
There are 3 main components:
|
||||
|
||||
- Rust binary with the global file picker state containing index of all files
|
||||
- Background thread with the file system watcher that updates the index in real time
|
||||
- Lua UI layer that renders the picker, handles user input, and calls the rust functions via FFI
|
||||
|
||||
There are 2 databases:
|
||||
|
||||
- Frecency database (LMDB) that tracks file access patterns for scoring
|
||||
- Query history database used to track the user's previous search queries
|
||||
|
||||
### Key Files
|
||||
|
||||
- `lua/fff.lua` - Entry point, delegates to main.lua
|
||||
- `lua/fff/main.lua` - Public API (find_files, search, change_directory)
|
||||
- `lua/fff/core.lua` - Initialization, autocmds, global state management
|
||||
- `lua/fff/picker_ui.lua` - UI rendering, layout calculation, keymaps
|
||||
- `lua/fff/file_picker/preview.lua` - File preview with syntax highlighting
|
||||
- `lua/fff/file_picker/image.lua` - Image preview (snacks.nvim integration)
|
||||
- `lua/fff/conf.lua` - Default config
|
||||
- `lua/fff/rust/init.lua` - Loads compiled Rust shared library
|
||||
|
||||
**Rust Side:**
|
||||
|
||||
- `lua/fff/rust/lib.rs` - FFI bindings, global state (FILE_PICKER, FRECENCY)
|
||||
- `lua/fff/rust/file_picker.rs` - Core FilePicker struct, indexing, background watcher
|
||||
- `lua/fff/rust/frecency.rs` - Frecency database (LMDB) and scoring
|
||||
- `lua/fff/rust/query_tracker.rs` - Search query history tracking
|
||||
- `lua/fff/rust/score.rs` - Fuzzy match scoring with frizbee integration
|
||||
- `lua/fff/rust/git.rs` - Git status caching and repository detection
|
||||
- `lua/fff/rust/background_watcher.rs` - File system watcher thread
|
||||
|
||||
### Scoring Algorithm
|
||||
|
||||
Located at the score.rs file
|
||||
|
||||
### Build System
|
||||
|
||||
- `Cargo.toml` - Rust dependencies and build configuration (package name: `fff_nvim`)
|
||||
- `rust-toolchain.toml` - Specifies Rust nightly toolchain with required components
|
||||
- `Cross.toml` - Cross-compilation settings using Zig for Linux targets
|
||||
- **CI/CD Workflows**:
|
||||
- `.github/workflows/rust.yml` - Rust testing, formatting, and clippy checks
|
||||
- `.github/workflows/release.yaml` - Automated multi-platform builds
|
||||
- `.github/workflows/stylua.yaml` - Lua code formatting validation
|
||||
- `.github/workflows/nix.yml` - Nix build validation
|
||||
- **Cross-compilation Support**: Uses `cross` tool with Zig backend for efficient cross-compilation
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Working with Rust Code
|
||||
|
||||
- Prefer struct methods over functions
|
||||
- If there is more than 2 impls in the file - create new file
|
||||
- Smaller concise comments over giant comment blocks
|
||||
- Do not add doc comments to the private functions/structs
|
||||
- Be very careful around locking and better double check with the human if something is going to require potentially long lock on a mutex/rwlock
|
||||
|
||||
### Working with lua code
|
||||
|
||||
- Document the types of public functions in every module
|
||||
- Use `vim.validate()` for validating user inputs in public functions
|
||||
- Try to reuse as much of existing functions as possible
|
||||
- When working on new features for the UI **IT IS EXTREMELY IMPORTANT** to keep the core functionality of navigating between files, selecting, and seeing the preview working as is. NEVER break anything from the core UI functionality, only add new features on top of the current UI.
|
||||
- When making a large chunk of code make lua test that opens neovim at `~/dev/lightsource` and opens the picker to test the ui functionality across the actual code.
|
||||
- When adding a new highlights or any new shortcuts and configurable UI options add them to the neovim config. AND IMPORTANT: update the README.md with the new configuration options.
|
||||
|
||||
### UI rendering
|
||||
|
||||
When working on the UI changeds IT IS EXTREMELY important for you to test it for both prompt_position="bottom" and prompt_position="top" as the rendering logic is different for both of them in both rust and lua world. When the prompt is positioed in the bottom everything should work the same way as the top but would be reversed in order. (though navigation is same for both)
|
||||
|
||||
## Top level API that can not introduce breaking changes under any circumstance
|
||||
|
||||
Top level rust, lua, C, and bun APIs can not be changed under any circumstance
|
||||
Generated
+166
-60
@@ -474,6 +474,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
@@ -629,7 +639,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fff-c"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"fff-query-parser",
|
||||
"fff-search",
|
||||
@@ -642,7 +652,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-grep"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"memchr",
|
||||
@@ -650,7 +660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-mcp"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fff-query-parser",
|
||||
@@ -665,9 +675,23 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-notify-debouncer-full"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c6f0c16164d10c082af931377766f5495440bee66a9a841bbcea1af5de8878c"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"rustc-hash 2.1.2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-nvim"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"blake3",
|
||||
@@ -675,6 +699,7 @@ dependencies = [
|
||||
"criterion",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"fff-notify-debouncer-full",
|
||||
"fff-query-parser",
|
||||
"fff-search",
|
||||
"git2",
|
||||
@@ -685,10 +710,10 @@ dependencies = [
|
||||
"mlua",
|
||||
"neo_frizbee",
|
||||
"notify",
|
||||
"notify-debouncer-full 0.6.0",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rayon",
|
||||
"serde",
|
||||
"smallvec",
|
||||
@@ -700,7 +725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-query-parser"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"zlob",
|
||||
@@ -708,7 +733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fff-search"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"aho-corasick",
|
||||
@@ -716,27 +741,32 @@ dependencies = [
|
||||
"blake3",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"ctor",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"fff-grep",
|
||||
"fff-notify-debouncer-full",
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"globset",
|
||||
"heed",
|
||||
"ignore",
|
||||
"libc",
|
||||
"libmimalloc-sys",
|
||||
"memchr",
|
||||
"memmap2",
|
||||
"mimalloc",
|
||||
"neo_frizbee",
|
||||
"notify",
|
||||
"notify-debouncer-full 0.7.0",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"proptest",
|
||||
"rand 0.8.5",
|
||||
"rayon",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
@@ -765,6 +795,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -780,15 +816,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -1477,7 +1504,7 @@ dependencies = [
|
||||
"mlua_derive",
|
||||
"num-traits",
|
||||
"parking_lot",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
@@ -1506,9 +1533,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.8.5"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34a8a11f48357cecd5ada0751f1490d8da4dd935e595d34cc2727567d5288ead"
|
||||
checksum = "2b216854d8c3752f8dbda56703c708de44bccb0542ca3924976c5977d6fb0724"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"raw-cpuid",
|
||||
@@ -1538,46 +1565,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.2.0"
|
||||
version = "9.0.0-rc.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
checksum = "783683ce6e1059e3747190a6c05688db21e07a846bf90babb351365f9133fe3e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-services",
|
||||
"walkdir",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.61.2",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1622,6 +1625,25 @@ dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-services"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -1720,7 +1742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1828,6 +1850,29 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -1856,8 +1901,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1867,7 +1922,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1879,6 +1944,24 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
@@ -2020,9 +2103,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
@@ -2043,6 +2126,17 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2535,6 +2629,12 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -3054,6 +3154,12 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -3153,9 +3259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlob"
|
||||
version = "1.3.0"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07934fb13f0f4e14281bab6b0e984cfc03891d2cf5b0a8bd0ab14fd240e21106"
|
||||
checksum = "41f3522fa9701b74ec72758aedb96da278f6e0533bc16b6a79090bfb465d4661"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.11.0",
|
||||
|
||||
+21
-6
@@ -10,8 +10,8 @@ members = [
|
||||
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 }
|
||||
fff-grep = { version = "0.7.1", path = "crates/fff-grep" }
|
||||
fff-query-parser = { version = "0.7.1", path = "crates/fff-query-parser", default-features = false }
|
||||
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
@@ -31,17 +31,18 @@ heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
zlob = "1.3.0"
|
||||
zlob = "1.3.3"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.8.5", features = ["match_end_col"] }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.7"
|
||||
neo_frizbee = { version = "0.10.1", features = ["match_end_col"] }
|
||||
notify = { version = "9.0.0-rc.3" }
|
||||
notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.3" }
|
||||
once_cell = "1.20.2"
|
||||
parking_lot = "0.12"
|
||||
pathdiff = "0.2.1"
|
||||
rayon = "1.8.0"
|
||||
regex = "1.11"
|
||||
regex-syntax = "0.8"
|
||||
smallvec = { version = "1.13", features = ["const_generics", "union"] }
|
||||
thiserror = "2.0.10"
|
||||
tracing = "0.1"
|
||||
@@ -52,5 +53,19 @@ lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
# we use lto=fat locally for better SIMD for the march=native but
|
||||
# on CI when we cross compiling we should not exclude any cpu flags checking
|
||||
lto = "thin"
|
||||
|
||||
[profile.bench]
|
||||
inherits = "release"
|
||||
|
||||
# For Instruments / xctrace: release-level optimization but keep debuginfo
|
||||
# and symbols so sampled frames resolve to real Rust names.
|
||||
[profile.prof]
|
||||
inherits = "release"
|
||||
debug = "full"
|
||||
strip = false
|
||||
lto = "thin"
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
PLENARY_DIR ?= ../plenary.nvim
|
||||
|
||||
.PHONY: build test test-rust test-lua test-version test-bun test-node prepare-bun prepare-node set-npm-version header
|
||||
PREFIX ?= /usr/local
|
||||
LIBDIR ?= $(PREFIX)/lib
|
||||
INCLUDEDIR ?= $(PREFIX)/include
|
||||
|
||||
# Compile-time cfg that gates the watcher + git-status fuzz stress test.
|
||||
STRESS_RUSTFLAGS := --cfg stress
|
||||
FFF_STRESS_DEFAULT_SEED ?= 0xDEADBEEFCAFEBABE
|
||||
|
||||
.PHONY: build build-c-lib install uninstall test test-rust test-lua test-version test-bun test-node prepare-bun prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random
|
||||
|
||||
all: format test lint
|
||||
|
||||
build:
|
||||
cargo build --release --features zlob
|
||||
|
||||
build-c-lib:
|
||||
cargo build --release -p fff-c --features zlob
|
||||
|
||||
header:
|
||||
cbindgen --config crates/fff-c/cbindgen.toml --crate fff-c --output crates/fff-c/include/fff.h
|
||||
|
||||
# Install the C library and header under $(PREFIX) (default /usr/local).
|
||||
# Override PREFIX for user-local installs, e.g. `make install PREFIX=$$HOME/.local`.
|
||||
# DESTDIR is honoured for packagers.
|
||||
install: build-c-lib
|
||||
install -d $(DESTDIR)$(LIBDIR)
|
||||
install -d $(DESTDIR)$(INCLUDEDIR)
|
||||
install -m 0644 crates/fff-c/include/fff.h $(DESTDIR)$(INCLUDEDIR)/fff.h
|
||||
@if [ -f target/release/libfff_c.dylib ]; then \
|
||||
install -m 0755 target/release/libfff_c.dylib $(DESTDIR)$(LIBDIR)/libfff_c.dylib; \
|
||||
echo "Installed $(DESTDIR)$(LIBDIR)/libfff_c.dylib"; \
|
||||
fi
|
||||
@if [ -f target/release/libfff_c.so ]; then \
|
||||
install -m 0755 target/release/libfff_c.so $(DESTDIR)$(LIBDIR)/libfff_c.so; \
|
||||
echo "Installed $(DESTDIR)$(LIBDIR)/libfff_c.so"; \
|
||||
fi
|
||||
@if [ -f target/release/fff_c.dll ]; then \
|
||||
install -m 0755 target/release/fff_c.dll $(DESTDIR)$(LIBDIR)/fff_c.dll; \
|
||||
echo "Installed $(DESTDIR)$(LIBDIR)/fff_c.dll"; \
|
||||
fi
|
||||
@echo "Installed header $(DESTDIR)$(INCLUDEDIR)/fff.h"
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(LIBDIR)/libfff_c.dylib
|
||||
rm -f $(DESTDIR)$(LIBDIR)/libfff_c.so
|
||||
rm -f $(DESTDIR)$(LIBDIR)/fff_c.dll
|
||||
rm -f $(DESTDIR)$(INCLUDEDIR)/fff.h
|
||||
@echo "Removed fff-c from $(DESTDIR)$(PREFIX)"
|
||||
|
||||
test-setup:
|
||||
@if [ ! -d "$(PLENARY_DIR)" ]; then \
|
||||
echo "Cloning plenary.nvim..."; \
|
||||
@@ -21,7 +60,7 @@ test-rust:
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
-c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}" 2>&1
|
||||
|
||||
test-version: test-setup
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
@@ -43,12 +82,33 @@ prepare-node: build
|
||||
|
||||
test-bun: prepare-bun
|
||||
cd packages/fff-bun && bun test src/
|
||||
cd packages/pi-fff && bun test test/
|
||||
|
||||
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
|
||||
|
||||
|
||||
test-stress-seeded:
|
||||
FFF_STRESS_SEED="$${FFF_STRESS_SEED:-$(FFF_STRESS_DEFAULT_SEED)}" \
|
||||
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
|
||||
cargo test \
|
||||
-p fff-search \
|
||||
--test fuzz_git_watcher_stress \
|
||||
--features zlob \
|
||||
-- --nocapture stress_seeded
|
||||
|
||||
test-stress-random:
|
||||
RUSTFLAGS="$(STRESS_RUSTFLAGS)" \
|
||||
cargo test \
|
||||
-p fff-search \
|
||||
--test fuzz_git_watcher_stress \
|
||||
--features zlob \
|
||||
-- --nocapture stress_random
|
||||
|
||||
test-stress: test-stress-seeded test-stress-random
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -1,108 +1,146 @@
|
||||
<p align="center">
|
||||
<h1 align="center">FFF</h1>
|
||||
<img alt="FFF" src="./assets/logo-orange.png" width="300">
|
||||
|
||||
<p>
|
||||
<i>A file search toolkit for humans and AI agents. Really fast.</i>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#mcp"><strong>AI agents (MCP)</strong></a> | <a href="#neovim-guide"><strong>Neovim users</strong></a>
|
||||
</p>
|
||||
Typo-resistant path and content search, frecency-ranked file access, a background watcher, and a lightweight in-memory content index. Way faster than CLIs like ripgrep and fzf in any long-running process that searches more than once.
|
||||
|
||||
<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;">
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/stargazers" style="text-decoration: none">
|
||||
<img alt="Stars" src="https://img.shields.io/github/stars/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41"></a>
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/issues" style="text-decoration: none">
|
||||
<img alt="Issues" src="https://img.shields.io/github/issues/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=bilibili&color=F5E0DC&logoColor=D9E0EE&labelColor=302D41"></a>
|
||||
<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>
|
||||
Originally started as [Neovim plugin](#neovim-plugin) people loved, but it turned out that plenty of AI harnesses and code editors need the same thing: accurate, fast file search as a library. That is what fff is.
|
||||
|
||||
---
|
||||
|
||||
**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.
|
||||
Pick what you are interested in:
|
||||
|
||||
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.
|
||||
<details id="mcp-server">
|
||||
<summary>
|
||||
<h2>MCP server</h2>
|
||||
</summary>
|
||||
|
||||
## MCP
|
||||
Works with Claude Code, Codex, OpenCode, Cursor, Cline, and any MCP-capable client. Fewer grep roundtrips, less wasted context, faster answers.
|
||||
|
||||
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.
|
||||

|
||||
|
||||

|
||||
### One-line install
|
||||
|
||||
You can install FFF as a dependency for your AI agent using a simple bash script:
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
|
||||
```
|
||||
|
||||
> The installation script is here [./install-mcp.sh](./install-mcp.sh) if you want to review it before running.
|
||||
Windows (PowerShell):
|
||||
|
||||
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
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.ps1 | iex
|
||||
```
|
||||
|
||||
## Neovim guide
|
||||
The scripts live at [`install-mcp.sh`](./install-mcp.sh) and [`install-mcp.ps1`](./install-mcp.ps1) if you want to read them first.
|
||||
|
||||
Here is some demo on the linux repository (100k files, 8GB) but you better fill it yourself and see the magic
|
||||
It prints the exact wiring instructions for your client. Once the server is connected, ask the agent to "use fff" and it picks up the `ffgrep`, `fffind`, and `fff-multi-grep` tools.
|
||||
|
||||
### Recommended agent prompt
|
||||
|
||||
Drop this into your project's `CLAUDE.md` or equivalent:
|
||||
|
||||
```markdown
|
||||
For any file search or grep in the current git-indexed directory, use fff tools.
|
||||
```
|
||||
|
||||
### What changes
|
||||
|
||||
- Frecency memory. Files you actually open rank higher next time. Warm-up from git touch history runs automatically.
|
||||
- Definition-first hinting. Lines that look like code definitions are classified on the Rust side, no regex overhead in your prompt.
|
||||
- Smart-case with auto-fuzzy fallback. `IsOffTheRecord` finds snake_case variants; zero-match queries retry as fuzzy and surface the best approximate hits.
|
||||
- Git-aware annotations. Modified, untracked, and staged files are tagged so the agent reaches for what you are actively changing.
|
||||
|
||||
Source: [`crates/fff-mcp/`](./crates/fff-mcp/).
|
||||
|
||||
</details>
|
||||
|
||||
The MCP server gives any agent a file search tool that is faster and more token-efficient than the built-in one.
|
||||
|
||||
<details id="pi-extension">
|
||||
<summary>
|
||||
<h2>Pi agent extension</h2>
|
||||
</summary>
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pi install npm:@ff-labs/pi-fff
|
||||
```
|
||||
|
||||
### Modes
|
||||
|
||||
Three operating modes, switchable at runtime with `/fff-mode`:
|
||||
|
||||
| Mode | What it does |
|
||||
| ------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `tools-and-ui` (default) | Adds `ffgrep` and `fffind` tools, replaces `@`-mention autocomplete with FFF. |
|
||||
| `tools-only` | Only tool injection. Keeps pi's native editor autocomplete. |
|
||||
| `override` | Replaces pi's built-in `grep`, `find`, and `multi_grep` with FFF implementations. |
|
||||
|
||||
Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode`, `--fff-frecency-db`, `--fff-history-db`.
|
||||
|
||||
### Agent-facing tools
|
||||
|
||||
- `ffgrep`. Content search. Accepts `path`, `exclude` (comma, space, or array; leading `!` optional), `caseSensitive`, `context`, and cursor pagination. Auto-detects regex, falls back to fuzzy on zero exact matches, rejects `.*`-style wildcard-only patterns up front.
|
||||
- `fffind`. Path and filename search. Matches the whole repo-relative path, not just the filename. Frecency-aware. The weak-match detector flags scattered fuzzy noise before it floods the agent's context.
|
||||
|
||||
### Commands
|
||||
|
||||
- `/fff-mode [tools-and-ui | tools-only | override]`. Show or switch the mode.
|
||||
- `/fff-health`. Picker, frecency, and git integration status.
|
||||
- `/fff-rescan`. Force a rescan.
|
||||
|
||||
Source: [`packages/pi-fff/`](./packages/pi-fff/).
|
||||
|
||||
</details>
|
||||
|
||||
The Pi extension swaps pi's native tools for FFF implementations and feeds the interactive editor's `@`-mention autocomplete from the frecency-ranked index.
|
||||
|
||||
<details id="neovim-plugin">
|
||||
<summary>
|
||||
<h2>Neovim plugin</h2>
|
||||
</summary>
|
||||
|
||||
Demo on the Linux kernel repo (100k files, 8GB):
|
||||
|
||||
https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb
|
||||
|
||||
### Installation
|
||||
|
||||
FFF.nvim requires neovim 0.10.0 or higher
|
||||
|
||||
#### lazy.nvim
|
||||
|
||||
```lua
|
||||
{
|
||||
'dmtrKovalenko/fff.nvim',
|
||||
build = function()
|
||||
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
|
||||
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
|
||||
-- downloads a prebuilt binary or falls back to cargo build
|
||||
require("fff.download").download_or_build_binary()
|
||||
end,
|
||||
-- if you are using nixos
|
||||
-- for nixos:
|
||||
-- build = "nix run .#release",
|
||||
opts = { -- (optional)
|
||||
opts = {
|
||||
debug = {
|
||||
enabled = true, -- we expect your collaboration at least during the beta
|
||||
show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
|
||||
enabled = true,
|
||||
show_scores = true,
|
||||
},
|
||||
},
|
||||
-- No need to lazy-load with lazy.nvim.
|
||||
-- This plugin initializes itself lazily.
|
||||
lazy = false,
|
||||
lazy = false, -- the plugin lazy-initialises itself
|
||||
keys = {
|
||||
{
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
},
|
||||
{
|
||||
"fg",
|
||||
function() require('fff').live_grep() end,
|
||||
desc = 'LiFFFe grep',
|
||||
},
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
{ "ff", function() require('fff').find_files() end, desc = 'FFFind files' },
|
||||
{ "fg", function() require('fff').live_grep() end, desc = 'LiFFFe grep' },
|
||||
{ "fz",
|
||||
function() require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
{ "fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -112,389 +150,405 @@ FFF.nvim requires neovim 0.10.0 or higher
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
|
||||
|
||||
vim.api.nvim_create_autocmd('PackChanged', {
|
||||
callback = function(event)
|
||||
if event.data.updated then
|
||||
callback = function(ev)
|
||||
local name, kind = ev.data.spec.name, ev.data.kind
|
||||
if name == 'fff.nvim' and (kind == 'install' or kind == 'update') then
|
||||
if not ev.data.active then vim.cmd.packadd('fff.nvim') end
|
||||
require('fff.download').download_or_build_binary()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- the plugin will automatically lazy load
|
||||
vim.g.fff = {
|
||||
lazy_sync = true, -- start syncing only when the picker is open
|
||||
debug = {
|
||||
enabled = true,
|
||||
show_scores = true,
|
||||
},
|
||||
lazy_sync = true,
|
||||
debug = { enabled = true, show_scores = true },
|
||||
}
|
||||
|
||||
vim.keymap.set(
|
||||
'n',
|
||||
'ff',
|
||||
function() require('fff').find_files() end,
|
||||
{ desc = 'FFFind files' }
|
||||
)
|
||||
vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'FFFind files' })
|
||||
```
|
||||
|
||||
### Public API
|
||||
|
||||
```lua
|
||||
require('fff').find_files() -- find files in current repo
|
||||
require('fff').live_grep() -- live content grep
|
||||
require('fff').scan_files() -- force rescan
|
||||
require('fff').refresh_git_status() -- refresh git status
|
||||
require('fff').find_files_in_dir(path) -- find in a specific dir
|
||||
require('fff').change_indexing_directory(new_path) -- change root
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
- `:FFFScan`. Rescan files.
|
||||
- `:FFFRefreshGit`. Refresh git status.
|
||||
- `:FFFClearCache [all|frecency|files]`. Clear caches.
|
||||
- `:FFFHealth`. Health check.
|
||||
- `:FFFDebug [on|off|toggle]`. Toggle the scoring display.
|
||||
- `:FFFOpenLog`. Open `~/.local/state/nvim/log/fff.log`.
|
||||
|
||||
### Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete configuration with all available options:
|
||||
Defaults are sensible. Override only what you care about.
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '🪿 ',
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
|
||||
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Show file info panel in preview
|
||||
show_scores = false, -- Show scores inline in the UI
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
#### Available Methods
|
||||
|
||||
```lua
|
||||
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 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:
|
||||
|
||||
- `: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
|
||||
- `:FFFHealth` - Check FFF health status and dependencies
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Toggle scoring information display:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Use `:FFFDebug` command
|
||||
- Enable by default with `debug.show_scores = true`
|
||||
|
||||
#### Multi-Select and Quickfix Integration
|
||||
|
||||
Select multiple files and send them to Neovim's quickfix list (keymaps are configurable):
|
||||
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
#### Live Grep Search Modes
|
||||
|
||||
Live grep supports three search modes, cycled with `<S-Tab>`:
|
||||
|
||||
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
|
||||
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
|
||||
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
|
||||
|
||||
The current mode is shown on the right side of the input field (e.g., `plain`, `regex`, `fuzzy`) with color-coded highlighting.
|
||||
|
||||
You can customize which modes are available and their cycling order globally in your configuration, or per-call when invoking `live_grep()`.
|
||||
|
||||
**Global configuration:**
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
grep = {
|
||||
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Per-call configuration:**
|
||||
|
||||
```lua
|
||||
-- Only fuzzy and plain modes for this specific grep
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Single mode (hides mode indicator completely)
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Pre-fill the search with an initial query
|
||||
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:
|
||||
|
||||
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
|
||||
- **Grep search with no matches** → shows suggested **file name matches** for the same query
|
||||
|
||||
Suggestions are clearly labeled with a "No results found. Suggested ..." banner (highlighted with `hl.suggestion_header`). You can navigate and select suggestion items just like normal results — selecting a grep suggestion will open the file at the matching line.
|
||||
|
||||
#### Git Status Highlighting
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
|
||||
|
||||
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
|
||||
|
||||
```lua
|
||||
hl = {
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
}
|
||||
```
|
||||
|
||||
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
|
||||
|
||||
To enable git status text coloring, set `git.status_text_color = true`:
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true, -- Enable git status colors on filename text
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '> ',
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true,
|
||||
prompt_vim_mode = false,
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- 'left' | 'right' | 'top' | 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { size = 130, wrap = 'top' },
|
||||
show_scrollbar = true,
|
||||
path_shorten_strategy = 'middle_number', -- 'middle_number' | 'middle' | 'end'
|
||||
anchor = 'center',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024,
|
||||
chunk_size = 8192,
|
||||
binary_file_threshold = 1024,
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both',
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
cycle_previous_query = '<C-Up>',
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3,
|
||||
combo_boost_score_multiplier = 100,
|
||||
},
|
||||
git = {
|
||||
status_text_color = false, -- true to color filenames by git status
|
||||
},
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024,
|
||||
max_matches_per_file = 100,
|
||||
smart_case = true,
|
||||
time_budget_ms = 150,
|
||||
modes = { 'plain', 'regex', 'fuzzy' },
|
||||
trim_whitespace = false,
|
||||
},
|
||||
debug = { enabled = false, show_scores = false },
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
hl = {
|
||||
git_staged = 'FFFGitStaged', -- Files staged for commit
|
||||
git_modified = 'FFFGitModified', -- Modified unstaged files
|
||||
git_deleted = 'FFFGitDeleted', -- Deleted files
|
||||
git_renamed = 'FFFGitRenamed', -- Renamed files
|
||||
git_untracked = 'FFFGitUntracked', -- New untracked files
|
||||
git_ignored = 'FFFGitIgnored', -- Git-ignored files
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
The plugin provides sensible default highlight groups that link to common git highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these with your own custom highlight groups to match your colorscheme.
|
||||
### Live grep modes
|
||||
|
||||
**Example - Custom Bright Colors for Text:**
|
||||
`<S-Tab>` cycles between `plain`, `regex`, and `fuzzy`. The list is configurable via `grep.modes`, and single-mode setups hide the indicator entirely.
|
||||
|
||||
Per-call override:
|
||||
|
||||
```lua
|
||||
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
|
||||
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
|
||||
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true,
|
||||
},
|
||||
hl = {
|
||||
git_modified = 'CustomGitModified',
|
||||
git_untracked = 'CustomGitUntracked',
|
||||
}
|
||||
})
|
||||
require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } })
|
||||
require('fff').live_grep({ query = 'search term' }) -- pre-fill
|
||||
```
|
||||
|
||||
#### File Filtering
|
||||
### Constraints
|
||||
|
||||
FFF.nvim respects `.gitignore` patterns automatically. To filter files from the picker without modifying `.gitignore`, create a `.ignore` file in your project root:
|
||||
Both find and grep accept these tokens to refine a query:
|
||||
|
||||
- `git:modified`. One of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`.
|
||||
- `test/`. Any deeply nested children of `test/`.
|
||||
- `!something`, `!test/`, `!git:modified`. Exclusion.
|
||||
- `./**/*.{rs,lua}`. Any valid glob, powered by [zlob](https://github.com/dmtrKovalenko/zlob).
|
||||
|
||||
Grep-only:
|
||||
|
||||
- `*.md`, `*.{c,h}`. Extension filter.
|
||||
- `src/main.rs`. Grep inside a single file.
|
||||
|
||||
Mix freely: `git:modified src/**/*.rs !src/**/mod.rs user controller`.
|
||||
|
||||
### Multi-select and quickfix
|
||||
|
||||
- `<Tab>`. Toggle selection (shows a thick `▊` in the signcolumn).
|
||||
- `<C-q>`. Send selected files to the quickfix list and close the picker.
|
||||
|
||||
### Git status highlighting
|
||||
|
||||
Sign-column indicators are on by default. To color filename text by git status, set `git.status_text_color = true` and adjust the `hl.git_*` groups. See `:help fff.nvim` for the full list.
|
||||
|
||||
### File filtering
|
||||
|
||||
FFF honours `.gitignore`. For picker-only ignores that do not touch git, add a sibling `.ignore` file:
|
||||
|
||||
```gitignore
|
||||
# Exclude all markdown files
|
||||
*.md
|
||||
|
||||
# Exclude specific subdirectory
|
||||
docs/archive/**/*.md
|
||||
```
|
||||
|
||||
Run `:FFFScan` to force a rescan if needed.
|
||||
Run `:FFFScan` to force a rescan.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Health Check
|
||||
- `:FFFHealth` verifies picker init, optional dependencies, and DB connectivity.
|
||||
- `:FFFOpenLog` opens the log file.
|
||||
|
||||
Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will verify:
|
||||
</details>
|
||||
|
||||
- File picker initialization status
|
||||
- Optional dependencies (git, image preview tools)
|
||||
- Database connectivity
|
||||
The best file search picker for neovim. Period. Faster and more intuitive queries, frecency ranking, definition classification and much more.
|
||||
|
||||
#### Viewing Logs
|
||||
<details id="node-sdk">
|
||||
<summary>
|
||||
<h2>Node & Bun SDK</h2>
|
||||
</summary>
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
```
|
||||
:FFFOpenLog
|
||||
```bash
|
||||
npm install @ff-labs/fff-node
|
||||
# or
|
||||
bun add @ff-labs/fff-node
|
||||
```
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
|
||||
```ts
|
||||
import { FileFinder } from "@ff-labs/fff-node";
|
||||
|
||||
const finder = FileFinder.create({ basePath: process.cwd(), aiMode: true });
|
||||
if (!finder.ok) throw new Error(finder.error);
|
||||
await finder.value.waitForScan(10_000);
|
||||
|
||||
const files = finder.value.fileSearch("incognito profile", { pageSize: 20 });
|
||||
const hits = finder.value.grep("GetOffTheRecordProfile", {
|
||||
mode: "plain",
|
||||
smartCase: true,
|
||||
beforeContext: 1,
|
||||
afterContext: 1,
|
||||
classifyDefinitions: true,
|
||||
});
|
||||
|
||||
finder.value.destroy();
|
||||
```
|
||||
|
||||
Every method returns a `Result<T>` (`{ ok: true, value } | { ok: false, error }`). Full type reference: [`packages/fff-node/src/types.ts`](./packages/fff-node/src/types.ts).
|
||||
|
||||
</details>
|
||||
|
||||
TypeScript wrapper over the C library for nodejs and bun. Build custom agent tools, CLIs, or IDE integrations on top of FFF.
|
||||
|
||||
<details id="rust-crate">
|
||||
<summary>
|
||||
<h2>Rust crate</h2>
|
||||
</summary>
|
||||
|
||||
### Add the dependency
|
||||
|
||||
FFF is written in Rust, so this is the lowest-overhead way to use it.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
fff-search = "0.6"
|
||||
```
|
||||
|
||||
Full API documentation: [docs.rs/fff-search](https://docs.rs/fff-search/latest/fff_search/).
|
||||
|
||||
</details>
|
||||
|
||||
Native rust crate that is performing all the search. Stable and well documented.
|
||||
|
||||
<details id="c-library">
|
||||
<summary>
|
||||
<h2>C library</h2>
|
||||
</summary>
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Builds only the C cdylib (fastest):
|
||||
make build-c-lib
|
||||
|
||||
# or directly with cargo:
|
||||
cargo build --release -p fff-c --features zlob
|
||||
```
|
||||
|
||||
The output is a `cdylib` (`libfff_c.so` / `libfff_c.dylib` / `fff_c.dll`). The header lives at [`crates/fff-c/include/fff.h`](./crates/fff-c/include/fff.h).
|
||||
|
||||
Prebuilt binaries for every version, including every commit on main, are on the [releases page](https://github.com/dmtrKovalenko/fff.nvim/releases). The same binaries also ship inside the `@ff-labs/fff-bin-*` npm packages.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# System-wide (needs sudo):
|
||||
sudo make install
|
||||
|
||||
# User-local, no sudo:
|
||||
make install PREFIX=$HOME/.local
|
||||
|
||||
# Staged install for packagers:
|
||||
make install DESTDIR=/tmp/pkgroot PREFIX=/usr
|
||||
```
|
||||
|
||||
Drops `libfff_c.{so,dylib,dll}` into `$(PREFIX)/lib` and the header into `$(PREFIX)/include/fff.h`. Remove with `make uninstall`, which honours the same `PREFIX` and `DESTDIR`.
|
||||
|
||||
Link against it after install:
|
||||
|
||||
```bash
|
||||
cc my_app.c -lfff_c -o my_app
|
||||
```
|
||||
|
||||
Ensure `$(PREFIX)/lib` is on your runtime library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS, or an entry in `/etc/ld.so.conf.d/`).
|
||||
|
||||
### Minimal example
|
||||
|
||||
```c
|
||||
#include <fff.h>
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
FffResult *res = fff_create_instance(
|
||||
".", // base_path
|
||||
"", // frecency_db_path (empty = default)
|
||||
"", // history_db_path
|
||||
false, // use_unsafe_no_lock
|
||||
true, // enable_mmap_cache
|
||||
true, // enable_content_indexing
|
||||
true, // watch
|
||||
false // ai_mode
|
||||
);
|
||||
if (!res->success) {
|
||||
fprintf(stderr, "init failed: %s\n", res->error);
|
||||
fff_free_result(res);
|
||||
return 1;
|
||||
}
|
||||
void *handle = res->handle;
|
||||
fff_free_result(res);
|
||||
|
||||
// Search
|
||||
FffResult *search = fff_search(handle, "main.rs", "", 0, 0, 20, 100, 3);
|
||||
// ... read FffSearchResult from search->handle, then fff_free_search_result()
|
||||
|
||||
fff_destroy(handle);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Every function returning `FffResult*` allocates with Rust's `Box`. Free with `fff_free_result`, do not use malloc's free
|
||||
- Payloads (search results, grep results, scan progress) have their own dedicated free functions listed in the header.
|
||||
- C strings returned in the `handle` field (e.g. from `fff_get_base_path`) are freed with `fff_free_string`.
|
||||
|
||||
Source: [`crates/fff-c/`](./crates/fff-c/).
|
||||
|
||||
</details>
|
||||
|
||||
Stable C ABI. Bind from C/C++, Zig, Go via cgo, Python via ctypes, or anything with C FFI.
|
||||
|
||||
---
|
||||
|
||||
## What is FFF and why use it over ripgrep or fzf?
|
||||
|
||||
FFF is a file search library, not a CLI. Ripgrep and fzf are great tools, but they are command-line programs: every call forks a new process, re-reads `.gitignore`, re-stats directories, and rebuilds whatever state it needs in memory before it can answer. That is fine when you grep once from a shell. It is bad when an editor or an AI agent wants to run hundreds of searches per session.
|
||||
|
||||
FFF keeps the index and the file cache resident in one long-lived process and exposes the same Rust core through four thin layers: a native crate (`fff-search`), a C library (`libfff_c`), a Node/Bun SDK (`@ff-labs/fff-node`), and an MCP server. You call `FileFinder.create()` once, then every subsequent search hits warm memory. On a 500k-file Chromium checkout, that is the difference between 3-9 **SECONDS** per ripgrep spawn and sub-10 ms per FFF query.
|
||||
|
||||
Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it is **typo-resistant** and we provide a query language with additional constraint parsing for prefiltering e.g. "*.rs !test/ shcema" is a perfectly valid query for fff, but fzf wouldn't find anything even for a single typo in "shcema".
|
||||
|
||||
### Why a programmatic API matters
|
||||
|
||||
- No process spawn. Every call stays in-process and avoids the fork, exec, argv parsing, and stdout pipe setup that dominates short `rg` invocations.
|
||||
- One FS walk, metadata collection, and parse of `.gitignore`. The ignore walker runs once at scan time and the result is reused for every search.
|
||||
- Results come back as typed objects, not text you have to re-parse. The SDK gives you `{ relativePath, lineNumber, lineContent, gitStatus, totalFrecencyScore, isDefinition, ... }` directly.
|
||||
- Cursor pagination that survives across calls. Ripgrep has no concept of "page 2 of these matches"; FFF does.
|
||||
- A long-lived process opens up optimisations that a one-shot CLI cannot apply: warm caches, incremental re-indexing, cross-query frecency, and shared SIMD state.
|
||||
|
||||
### What the core actually does
|
||||
|
||||
- **Frecency-ranked fuzzy matching.** Every indexed file carries an access score and a modification score. Searches rank files you have opened recently and frequently above cold results. This is the same idea as VS Code's recently-opened list, but applied to every search result, not just a sidebar.
|
||||
- **Typo-resistant matching for both paths and content.** Smith-Waterman fuzzy scoring is available on the grep path; path search uses SIMD-accelerated fuzzy matching (via the [`frizbee`](https://github.com/saghm/frizbee)-derived core) that survives dropped characters and reorderings.
|
||||
- **Content grep with three modes.** Plain literal (SIMD memmem), regex (the Rust `regex` crate), and fuzzy (Smith-Waterman per line). Auto-detects which mode to use from the pattern, falls back to fuzzy when a plain search returns zero hits.
|
||||
- **Multi-pattern OR search.** SIMD Aho-Corasick for "find any of these 20 identifiers at once", which is faster than regex alternation and a lot faster than 20 separate ripgrep runs.
|
||||
- **Background file watcher.** The index updates as files change. You never pay for a rescan on the hot path.
|
||||
- **Git status awareness.** Modified, staged, untracked, and ignored states are cached and returned with every result, so callers can sort or filter them without shelling out to git. The watcher talks to libgit2 directly instead of spawning the `git` CLI.
|
||||
- **Definition classifier.** A byte-level scanner on the Rust side tags lines that start with `struct`, `fn`, `class`, `def`, `impl`, and friends.
|
||||
|
||||
### Performance choices that matter
|
||||
|
||||
- Efficient memory allocator and memory allocation strategy (see next paragraph). By default we use `mimaloc`
|
||||
- Parallel multi thread search pipeline that is not contaganted by the orchistration logic
|
||||
- SIMD first algorithms for everything. Efficinet & non-allocating sorting.
|
||||
- Platform specific optimizations for FS ([getdents64](https://linux.die.net/man/2/getdents64), NTFS api on windows and others)
|
||||
- Lightweight on the flight content index for realtime even typo resistant grep
|
||||
- Memory mapped content cache. We store some of the files in virtual memory (the amount is limited)
|
||||
- Single contiguous arena storage of string chunks. Significantly reduces the amount of memory to work with and dramatically increases CPU cache hits.
|
||||
|
||||
### Memory allocation
|
||||
|
||||
Yes, fff fundamentally requires more memory than calling a single child process. That is the primary source of the speedup. In practice, alongside one of the most popular file search pickers for Neovim, [fff ends up using less RAM than a burst of ripgrep invocations](https://x.com/neogoose_btw/status/2041606853155811442).
|
||||
|
||||
|
||||
FFF also keeps a content index, around 360 bytes per indexed file, so roughly 36 MB for a 100k-file repo. Not every file is indexed - binaries, oversized files, and anything not eligible for grep are skipped. If even that footprint is too much, the index can be backed by a memory-mapped file instead of anonymous RAM.
|
||||
|
||||
### What this means in practice
|
||||
|
||||
If you are building an agent, an IDE extension, a pre-commit check, or any long-running tool that searches the same repository many times, calling FFF as a library is dramatically cheaper than shelling out to ripgrep. The tradeoff is real memory: FFF keeps the index in RAM and warms the content cache. On a 14k-file repo that costs about 26 MB resident. On a 500k-file repo like Chromium, expect a few hundred MB. In exchange, every single search is enriched with git status, frecency ranking, file metadata, timestamps of last access and edit and so on.
|
||||
|
||||
If you are running one grep from a terminal, `rg` is still the right tool. If you run dozens of them inside the same process, FFF will pay for itself starting from the second call. If you work on AI agent fff will finish preparation work before your AI will have a chance to call it.
|
||||
|
||||
### How it compares
|
||||
|
||||
- **ripgrep**: FFF uses the same underlying regex engine and more advanced plain text matching algorithms. Stores content index and file tree. Main wins on repeated-search workloads. Loses on "grep once from bash and exit."
|
||||
- **fzf**: FFF's path search is fuzzy like fzf, but it is also frecency-aware and git-aware, and ships a more typo-tolerant algorithm. fzf is a pure match-and-filter tool; FFF ranks results by how often you actually open them.
|
||||
- **Telescope / fzf-lua / snacks.picker**: FFF ships its own Neovim picker with the same ranking the MCP server and SDK use. The picker is optional; the core is the same.
|
||||
- **Tantivy or other full-text search engines**: different class of tool. Tantivy indexes documents for query-time scoring at scale. FFF is scoped to one repository and optimised for sub-10 ms response. It does not persist an inverted index on disk.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `crates/fff-search`, `crates/fff-grep`, `crates/fff-query-parser` - Rust core.
|
||||
- `crates/fff-c` - C FFI used by every language binding.
|
||||
- `crates/fff-nvim` - Lua/mlua bindings for the Neovim plugin.
|
||||
- `crates/fff-mcp` - MCP server binary.
|
||||
- `packages/fff-node` - Node.js SDK (`@ff-labs/fff-node`).
|
||||
- `packages/fff-bun` - Bun SDK (`@ff-labs/fff-node`).
|
||||
- `packages/pi-fff` - pi extension (`@ff-labs/pi-fff`).
|
||||
- `lua/` - Neovim-side plugin code.
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports and pull requests welcome. Agentic coding tools are welcome to be used, but human review is mandatory.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE) & open source forever.
|
||||
|
||||
@@ -4,6 +4,14 @@ extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
|
||||
[default.extend-words]
|
||||
noice = "noice"
|
||||
fo = "fo"
|
||||
ba = "ba"
|
||||
ue = "ue"
|
||||
# file extensions that look like typos
|
||||
thm = "thm"
|
||||
# some typos we use for tests
|
||||
comparsion = "comparsion"
|
||||
modfiers = "modfiers"
|
||||
shcema = "shcema"
|
||||
|
||||
[default]
|
||||
extend-ignore-re = [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"files": {
|
||||
"includes": ["packages/**/*.ts", "!packages/*/dist"],
|
||||
"ignoreUnknown": true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-c"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
description = "Raw C api of FFF file finder"
|
||||
license = "MIT"
|
||||
@@ -17,7 +17,7 @@ mimalloc.workspace = true
|
||||
tracing.workspace = true
|
||||
git2.workspace = true
|
||||
|
||||
fff = { package = "fff-search", path = "../fff-core" , version = "0.5.1" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.5.2" }
|
||||
fff = { package = "fff-search", path = "../fff-core" , version = "0.7.1" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.7.1" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
+690
-16
@@ -64,7 +64,6 @@ typedef struct FffResult {
|
||||
* Free the entire result with `fff_free_search_result`.
|
||||
*/
|
||||
typedef struct FffFileItem {
|
||||
char *path;
|
||||
char *relative_path;
|
||||
char *file_name;
|
||||
char *git_status;
|
||||
@@ -88,6 +87,7 @@ typedef struct FffScore {
|
||||
int32_t distance_penalty;
|
||||
int32_t current_file_penalty;
|
||||
int32_t combo_match_boost;
|
||||
int32_t path_alignment_bonus;
|
||||
bool exact_match;
|
||||
char *match_type;
|
||||
} FffScore;
|
||||
@@ -156,7 +156,6 @@ typedef struct FffMatchRange {
|
||||
* `FffGrepResult` with `fff_free_grep_result` to release everything.
|
||||
*/
|
||||
typedef struct FffGrepMatch {
|
||||
char *path;
|
||||
char *relative_path;
|
||||
char *file_name;
|
||||
char *git_status;
|
||||
@@ -223,38 +222,196 @@ typedef struct 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;
|
||||
bool is_watcher_ready;
|
||||
bool is_warmup_complete;
|
||||
} FffScanProgress;
|
||||
|
||||
/**
|
||||
* Create a new file finder instance.
|
||||
* A directory item returned by `fff_search_directories`.
|
||||
*
|
||||
* All string fields are heap-allocated and owned by the parent `FffDirSearchResult`.
|
||||
* Free the entire result with `fff_free_dir_search_result`.
|
||||
*/
|
||||
typedef struct FffDirItem {
|
||||
char *relative_path;
|
||||
char *dir_name;
|
||||
int32_t max_access_frecency;
|
||||
} FffDirItem;
|
||||
|
||||
/**
|
||||
* Directory search result returned by `fff_search_directories`.
|
||||
*
|
||||
* The caller must free this with `fff_free_dir_search_result`.
|
||||
*/
|
||||
typedef struct FffDirSearchResult {
|
||||
/**
|
||||
* Pointer to a heap-allocated array of `FffDirItem` (length = `count`).
|
||||
*/
|
||||
struct FffDirItem *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 directories that matched the query.
|
||||
*/
|
||||
uint32_t total_matched;
|
||||
/**
|
||||
* Total number of indexed directories.
|
||||
*/
|
||||
uint32_t total_dirs;
|
||||
} FffDirSearchResult;
|
||||
|
||||
/**
|
||||
* A single item in a mixed (files + directories) search result.
|
||||
*
|
||||
* `item_type`: 0 = file, 1 = directory.
|
||||
* All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`.
|
||||
*/
|
||||
typedef struct FffMixedItem {
|
||||
/**
|
||||
* 0 = file, 1 = directory.
|
||||
*/
|
||||
uint8_t item_type;
|
||||
char *relative_path;
|
||||
/**
|
||||
* Filename for files, last directory segment for directories.
|
||||
*/
|
||||
char *display_name;
|
||||
char *git_status;
|
||||
uint64_t size;
|
||||
uint64_t modified;
|
||||
/**
|
||||
* The access frecency score for files, or max access frecency among all the immediate
|
||||
* children for directories.
|
||||
*/
|
||||
int64_t access_frecency_score;
|
||||
/**
|
||||
* Always 0 for directories
|
||||
*/
|
||||
int64_t modification_frecency_score;
|
||||
/**
|
||||
* Always 0 for directories
|
||||
*/
|
||||
int64_t total_frecency_score;
|
||||
/**
|
||||
* Always 0 for directories
|
||||
*/
|
||||
bool is_binary;
|
||||
} FffMixedItem;
|
||||
|
||||
/**
|
||||
* Mixed search result returned by `fff_search_mixed`.
|
||||
*
|
||||
* The caller must free this with `fff_free_mixed_search_result`.
|
||||
*/
|
||||
typedef struct FffMixedSearchResult {
|
||||
/**
|
||||
* Pointer to a heap-allocated array of `FffMixedItem` (length = `count`).
|
||||
*/
|
||||
struct FffMixedItem *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 items (files + dirs) that matched the query.
|
||||
*/
|
||||
uint32_t total_matched;
|
||||
/**
|
||||
* Total number of indexed files.
|
||||
*/
|
||||
uint32_t total_files;
|
||||
/**
|
||||
* Total number of indexed directories.
|
||||
*/
|
||||
uint32_t total_dirs;
|
||||
/**
|
||||
* Location parsed from the query string.
|
||||
*/
|
||||
struct FffLocation location;
|
||||
} FffMixedSearchResult;
|
||||
|
||||
/**
|
||||
* Create a new file finder instance (legacy signature).
|
||||
*
|
||||
* @deprecated prefer `fff_create_instance2`, which also exposes log file and
|
||||
* cache-budget configuration. This function delegates to `fff_create_instance2`
|
||||
* with NULL log paths and auto cache budget, so behaviour is unchanged.
|
||||
*
|
||||
* The `use_unsafe_no_lock` parameter is deprecated and ignored; see
|
||||
* [`fff_create_instance2`] for details.
|
||||
*
|
||||
* ## Safety
|
||||
* See `fff_create_instance2`.
|
||||
*/
|
||||
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 enable_mmap_cache,
|
||||
bool enable_content_indexing,
|
||||
bool watch,
|
||||
bool ai_mode);
|
||||
|
||||
/**
|
||||
* Create a new file finder instance (v2, with full options).
|
||||
*
|
||||
* 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)
|
||||
* * `base_path` – directory to index (required)
|
||||
* * `frecency_db_path` – frecency LMDB database path (NULL/empty to skip)
|
||||
* * `history_db_path` – query history LMDB database path (NULL/empty to skip)
|
||||
* * `use_unsafe_no_lock` – **deprecated, ignored.** Previously enabled
|
||||
* * `enable_mmap_cache` – pre-populate mmap caches after the initial scan
|
||||
* * `enable_content_indexing` – build content index after the initial scan
|
||||
* * `watch` – start a background file-system watcher for live updates
|
||||
* * `ai_mode` – enable AI-agent optimizations
|
||||
* * `log_file_path` – tracing log file path (NULL/empty to skip).
|
||||
* Only the first successful call in a process installs the subscriber;
|
||||
* subsequent calls are no-ops at the log layer.
|
||||
* * `log_level` – `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`
|
||||
* (NULL/empty defaults to `"info"`). Ignored when `log_file_path` is not set.
|
||||
* * `cache_budget_max_files` – content cache file-count cap (0 = auto)
|
||||
* * `cache_budget_max_bytes` – content cache byte cap (0 = auto)
|
||||
* * `cache_budget_max_file_size` – per-file byte cap (0 = auto)
|
||||
*
|
||||
* When all three `cache_budget_*` values are 0 the budget is auto-computed
|
||||
* from repo size after the initial scan. Otherwise an explicit budget is
|
||||
* used: any field left at 0 falls back to its `unlimited()` default.
|
||||
*
|
||||
* ## 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);
|
||||
struct FffResult *fff_create_instance2(const char *base_path,
|
||||
const char *frecency_db_path,
|
||||
const char *history_db_path,
|
||||
bool _use_unsafe_no_lock,
|
||||
bool enable_mmap_cache,
|
||||
bool enable_content_indexing,
|
||||
bool watch,
|
||||
bool ai_mode,
|
||||
const char *log_file_path,
|
||||
const char *log_level,
|
||||
uint64_t cache_budget_max_files,
|
||||
uint64_t cache_budget_max_bytes,
|
||||
uint64_t cache_budget_max_file_size);
|
||||
|
||||
/**
|
||||
* Destroy a file finder instance and free all its resources.
|
||||
@@ -291,6 +448,60 @@ struct FffResult *fff_search(void *fff_handle,
|
||||
int32_t combo_boost_multiplier,
|
||||
uint32_t min_combo_count);
|
||||
|
||||
/**
|
||||
* Perform fuzzy search on indexed directories.
|
||||
*
|
||||
* # Parameters
|
||||
*
|
||||
* * `fff_handle` – instance from `fff_create_instance`
|
||||
* * `query` – search query string
|
||||
* * `current_file` – path of the currently open file for distance scoring (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)
|
||||
*
|
||||
* ## 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_directories(void *fff_handle,
|
||||
const char *query,
|
||||
const char *current_file,
|
||||
uint32_t max_threads,
|
||||
uint32_t page_index,
|
||||
uint32_t page_size);
|
||||
|
||||
/**
|
||||
* Perform a mixed fuzzy search across both files and directories.
|
||||
*
|
||||
* Returns a single flat list where files and directories are interleaved
|
||||
* by total score in descending order. Each item has an `item_type` field
|
||||
* (0 = file, 1 = directory).
|
||||
*
|
||||
* # Parameters
|
||||
*
|
||||
* * `fff_handle` – instance from `fff_create_instance`
|
||||
* * `query` – search query string
|
||||
* * `current_file` – path of the currently open file (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_mixed(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.
|
||||
*
|
||||
@@ -380,6 +591,17 @@ struct FffResult *fff_scan_files(void *fff_handle);
|
||||
*/
|
||||
bool fff_is_scanning(void *fff_handle);
|
||||
|
||||
/**
|
||||
* Get the base path of the file picker.
|
||||
*
|
||||
* Returns an `FffResult` with a heap-allocated C string in the `handle`
|
||||
* field. Free the string with `fff_free_string` after reading it.
|
||||
*
|
||||
* ## Safety
|
||||
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
*/
|
||||
struct FffResult *fff_get_base_path(void *fff_handle);
|
||||
|
||||
/**
|
||||
* Get scan progress information.
|
||||
*
|
||||
@@ -396,6 +618,14 @@ struct FffResult *fff_get_scan_progress(void *fff_handle);
|
||||
*/
|
||||
struct FffResult *fff_wait_for_scan(void *fff_handle, uint64_t timeout_ms);
|
||||
|
||||
/**
|
||||
* Wait for the background file watcher to be ready.
|
||||
*
|
||||
* ## Safety
|
||||
* `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
*/
|
||||
struct FffResult *fff_wait_for_watcher(void *fff_handle, uint64_t timeout_ms);
|
||||
|
||||
/**
|
||||
* Restart indexing in a new directory.
|
||||
*
|
||||
@@ -536,4 +766,448 @@ void fff_free_result(struct FffResult *result_ptr);
|
||||
*/
|
||||
void fff_free_string(char *s);
|
||||
|
||||
/**
|
||||
* Free a directory search result returned by `fff_search_directories`.
|
||||
*
|
||||
* ## Safety
|
||||
* `result` must be a valid pointer previously returned via `FffResult.handle`
|
||||
* from `fff_search_directories`, or null (no-op).
|
||||
*/
|
||||
void fff_free_dir_search_result(struct FffDirSearchResult *result);
|
||||
|
||||
/**
|
||||
* Get a pointer to the `index`-th `FffDirItem` in a directory search result.
|
||||
*
|
||||
* ## Safety
|
||||
* `result` must be a valid `FffDirSearchResult` pointer from `fff_search_directories`.
|
||||
*/
|
||||
const struct FffDirItem *fff_dir_search_result_get_item(const struct FffDirSearchResult *result,
|
||||
uint32_t index);
|
||||
|
||||
/**
|
||||
* Get a pointer to the `index`-th `FffScore` in a directory search result.
|
||||
*
|
||||
* ## Safety
|
||||
* `result` must be a valid `FffDirSearchResult` pointer from `fff_search_directories`.
|
||||
*/
|
||||
const struct FffScore *fff_dir_search_result_get_score(const struct FffDirSearchResult *result,
|
||||
uint32_t index);
|
||||
|
||||
/**
|
||||
* Free a mixed search result returned by `fff_search_mixed`.
|
||||
*
|
||||
* ## Safety
|
||||
* `result` must be a valid pointer previously returned via `FffResult.handle`
|
||||
* from `fff_search_mixed`, or null (no-op).
|
||||
*/
|
||||
void fff_free_mixed_search_result(struct FffMixedSearchResult *result);
|
||||
|
||||
/**
|
||||
* Get a pointer to the `index`-th `FffMixedItem` in a mixed search result.
|
||||
*
|
||||
* ## Safety
|
||||
* `result` must be a valid `FffMixedSearchResult` pointer from `fff_search_mixed`.
|
||||
*/
|
||||
const struct FffMixedItem *fff_mixed_search_result_get_item(const struct FffMixedSearchResult *result,
|
||||
uint32_t index);
|
||||
|
||||
/**
|
||||
* Get a pointer to the `index`-th `FffScore` in a mixed search result.
|
||||
*
|
||||
* ## Safety
|
||||
* `result` must be a valid `FffMixedSearchResult` pointer from `fff_search_mixed`.
|
||||
*/
|
||||
const struct FffScore *fff_mixed_search_result_get_score(const struct FffMixedSearchResult *result,
|
||||
uint32_t index);
|
||||
|
||||
/**
|
||||
* Returns the relative path of a file item (e.g. `"src/main.rs"`).
|
||||
*
|
||||
* Returns null if `item` is null. The returned pointer is valid for the
|
||||
* lifetime of the owning `FffSearchResult`; do not free it directly.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
const char *fff_file_item_get_relative_path(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the file-name component of a file item (e.g. `"main.rs"`).
|
||||
*
|
||||
* Returns null if `item` is null. Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
const char *fff_file_item_get_file_name(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the git status string for a file item (e.g. `"M "`, `"??"`)
|
||||
* or null if git is unavailable, the file is untracked, or `item` is null.
|
||||
*
|
||||
* Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
const char *fff_file_item_get_git_status(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the file size in bytes. Returns `0` if `item` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
uint64_t fff_file_item_get_size(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the last-modified time as seconds since the UNIX epoch.
|
||||
* Returns `0` if `item` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
uint64_t fff_file_item_get_modified(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the combined frecency score. Returns `0` if `item` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
int64_t fff_file_item_get_total_frecency_score(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the access-based frecency score. Returns `0` if `item` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
int64_t fff_file_item_get_access_frecency_score(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the modification-based frecency score. Returns `0` if `item` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
int64_t fff_file_item_get_modification_frecency_score(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns `true` if the file was detected as binary. Returns `false` if `item` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `item` must be a valid `FffFileItem` pointer or null.
|
||||
*/
|
||||
bool fff_file_item_get_is_binary(const struct FffFileItem *item);
|
||||
|
||||
/**
|
||||
* Returns the relative path of the file containing this grep match.
|
||||
*
|
||||
* Returns null if `m` is null. Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
const char *fff_grep_match_get_relative_path(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the file-name component of the file containing this grep match.
|
||||
*
|
||||
* Returns null if `m` is null. Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
const char *fff_grep_match_get_file_name(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the git status string for the matched file (e.g. `"M "`, `"??"`)
|
||||
* or null if git is unavailable, the file is untracked, or `m` is null.
|
||||
*
|
||||
* Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
const char *fff_grep_match_get_git_status(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the full text content of the matched line.
|
||||
*
|
||||
* Returns null if `m` is null. Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
const char *fff_grep_match_get_line_content(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the 1-based line number of the match within its file.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint64_t fff_grep_match_get_line_number(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the 0-based column of the match start within its line.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_match_get_col(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the byte offset of the match start from the beginning of the file.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint64_t fff_grep_match_get_byte_offset(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the file size in bytes for the matched file. Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint64_t fff_grep_match_get_size(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the combined frecency score for the matched file.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
int64_t fff_grep_match_get_total_frecency_score(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the access-based frecency score for the matched file.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
int64_t fff_grep_match_get_access_frecency_score(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the modification-based frecency score for the matched file.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
int64_t fff_grep_match_get_modification_frecency_score(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the last-modified time as seconds since the UNIX epoch for the matched file.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint64_t fff_grep_match_get_modified(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the number of highlight ranges in this match. Returns `0` if `m` is null.
|
||||
*
|
||||
* Use with [`fff_grep_match_get_match_range`] to iterate the highlight spans.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_match_get_match_ranges_count(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns a pointer to the `index`-th [`FffMatchRange`] highlight span.
|
||||
*
|
||||
* Returns null if `m` is null, `index >= match_ranges_count`, or the
|
||||
* ranges array is null. The returned pointer is valid until the owning
|
||||
* `FffGrepResult` is freed; do not free it directly.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
const struct FffMatchRange *fff_grep_match_get_match_range(const struct FffGrepMatch *m,
|
||||
uint32_t index);
|
||||
|
||||
/**
|
||||
* Returns the number of context lines captured before the match.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* Use with [`fff_grep_match_get_context_before`] to read each line.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_match_get_context_before_count(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the `index`-th context line before the match.
|
||||
*
|
||||
* Returns null if `m` is null, `index >= context_before_count`, or the
|
||||
* context array is null. Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
const char *fff_grep_match_get_context_before(const struct FffGrepMatch *m, uint32_t index);
|
||||
|
||||
/**
|
||||
* Returns the number of context lines captured after the match.
|
||||
* Returns `0` if `m` is null.
|
||||
*
|
||||
* Use with [`fff_grep_match_get_context_after`] to read each line.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_match_get_context_after_count(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the `index`-th context line after the match.
|
||||
*
|
||||
* Returns null if `m` is null, `index >= context_after_count`, or the
|
||||
* context array is null. Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
const char *fff_grep_match_get_context_after(const struct FffGrepMatch *m, uint32_t index);
|
||||
|
||||
/**
|
||||
* Returns the fuzzy match score. Returns `0` if `m` is null or no fuzzy
|
||||
* score is present.
|
||||
*
|
||||
* Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is
|
||||
* ambiguous without that flag.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
uint16_t fff_grep_match_get_fuzzy_score(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns `true` if this match carries a valid fuzzy score.
|
||||
* Returns `false` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
bool fff_grep_match_get_has_fuzzy_score(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns `true` if the match was identified as a symbol definition.
|
||||
* Returns `false` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
bool fff_grep_match_get_is_definition(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns `true` if the matched file was detected as binary.
|
||||
* Returns `false` if `m` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
*/
|
||||
bool fff_grep_match_get_is_binary(const struct FffGrepMatch *m);
|
||||
|
||||
/**
|
||||
* Returns the number of items in the result. Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffSearchResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_search_result_get_count(const struct FffSearchResult *r);
|
||||
|
||||
/**
|
||||
* Returns the total number of files that matched before the result was
|
||||
* truncated to the page size. Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffSearchResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_search_result_get_total_matched(const struct FffSearchResult *r);
|
||||
|
||||
/**
|
||||
* Returns the total number of indexed files considered during search.
|
||||
* Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffSearchResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_search_result_get_total_files(const struct FffSearchResult *r);
|
||||
|
||||
/**
|
||||
* Returns the number of matches in the result. Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffGrepResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_result_get_count(const struct FffGrepResult *r);
|
||||
|
||||
/**
|
||||
* Returns the total number of matches found across all pages.
|
||||
* Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffGrepResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_result_get_total_matched(const struct FffGrepResult *r);
|
||||
|
||||
/**
|
||||
* Returns the number of files actually opened and searched in this call.
|
||||
* Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffGrepResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_result_get_total_files_searched(const struct FffGrepResult *r);
|
||||
|
||||
/**
|
||||
* Returns the total number of indexed files before any filtering.
|
||||
* Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffGrepResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_result_get_total_files(const struct FffGrepResult *r);
|
||||
|
||||
/**
|
||||
* Returns the number of files eligible for search after path/type filtering.
|
||||
* Returns `0` if `r` is null.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffGrepResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_result_get_filtered_file_count(const struct FffGrepResult *r);
|
||||
|
||||
/**
|
||||
* Returns the file offset for the next page, or `0` if all files have been
|
||||
* searched or `r` is null. Pass this value as `file_offset` to a subsequent
|
||||
* `fff_live_grep` or `fff_multi_grep` call to continue pagination.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffGrepResult` pointer or null.
|
||||
*/
|
||||
uint32_t fff_grep_result_get_next_file_offset(const struct FffGrepResult *r);
|
||||
|
||||
/**
|
||||
* Returns the regex compilation error string if the engine fell back to
|
||||
* literal matching, or null if there was no error or `r` is null.
|
||||
*
|
||||
* Do not free the returned pointer.
|
||||
*
|
||||
* ## Safety
|
||||
* `r` must be a valid `FffGrepResult` pointer or null.
|
||||
*/
|
||||
const char *fff_grep_result_get_regex_fallback_error(const struct FffGrepResult *r);
|
||||
|
||||
#endif /* FFF_C_H */
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
//! Stable accessor functions for `fff-c` FFI struct fields.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! `fff-c` exposes its result types as plain `#[repr(C)]` structs. External
|
||||
//! consumers (Emacs Lisp via `emacs-ffi`, Python `ctypes`, etc.) that access
|
||||
//! fields by hardcoding byte offsets break silently whenever the struct layout
|
||||
//! changes — a new field shifts every subsequent offset with no compile-time
|
||||
//! warning.
|
||||
//!
|
||||
//! These functions turn field access into a **stable named API**: callers bind
|
||||
//! to a symbol name once and are fully insulated from layout changes.
|
||||
//!
|
||||
//! # Usage from Emacs Lisp (example)
|
||||
//!
|
||||
//! ```elisp
|
||||
//! (define-ffi-function fff--grep-match-line-content
|
||||
//! "fff_grep_match_get_line_content" :pointer [:pointer] fff--library)
|
||||
//!
|
||||
//! (ffi-get-c-string (fff--grep-match-line-content match-ptr))
|
||||
//! ```
|
||||
//!
|
||||
//! # Array iteration
|
||||
//!
|
||||
//! To walk result arrays use `fff_search_result_get_item`,
|
||||
//! `fff_grep_result_get_match`, and `fff_search_result_get_score` — these are
|
||||
//! defined in the main `lib.rs` FFI surface alongside the search functions.
|
||||
|
||||
use std::ffi::c_char;
|
||||
use std::ptr;
|
||||
|
||||
use crate::ffi_types::{FffFileItem, FffGrepMatch, FffGrepResult, FffMatchRange, FffSearchResult};
|
||||
|
||||
// ── FffFileItem ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the relative path of a file item (e.g. `"src/main.rs"`).
|
||||
///
|
||||
/// Returns null if `item` is null. The returned pointer is valid for the
|
||||
/// lifetime of the owning `FffSearchResult`; do not free it directly.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_relative_path(
|
||||
item: *const FffFileItem,
|
||||
) -> *const c_char {
|
||||
if item.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*item).relative_path }
|
||||
}
|
||||
|
||||
/// Returns the file-name component of a file item (e.g. `"main.rs"`).
|
||||
///
|
||||
/// Returns null if `item` is null. Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_file_name(item: *const FffFileItem) -> *const c_char {
|
||||
if item.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*item).file_name }
|
||||
}
|
||||
|
||||
/// Returns the git status string for a file item (e.g. `"M "`, `"??"`)
|
||||
/// or null if git is unavailable, the file is untracked, or `item` is null.
|
||||
///
|
||||
/// Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_git_status(item: *const FffFileItem) -> *const c_char {
|
||||
if item.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*item).git_status }
|
||||
}
|
||||
|
||||
/// Returns the file size in bytes. Returns `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_size(item: *const FffFileItem) -> u64 {
|
||||
if item.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*item).size }
|
||||
}
|
||||
|
||||
/// Returns the last-modified time as seconds since the UNIX epoch.
|
||||
/// Returns `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_modified(item: *const FffFileItem) -> u64 {
|
||||
if item.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*item).modified }
|
||||
}
|
||||
|
||||
/// Returns the combined frecency score. Returns `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_total_frecency_score(item: *const FffFileItem) -> i64 {
|
||||
if item.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*item).total_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the access-based frecency score. Returns `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_access_frecency_score(item: *const FffFileItem) -> i64 {
|
||||
if item.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*item).access_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the modification-based frecency score. Returns `0` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_modification_frecency_score(
|
||||
item: *const FffFileItem,
|
||||
) -> i64 {
|
||||
if item.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*item).modification_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns `true` if the file was detected as binary. Returns `false` if `item` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `item` must be a valid `FffFileItem` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_file_item_get_is_binary(item: *const FffFileItem) -> bool {
|
||||
if item.is_null() {
|
||||
return false;
|
||||
}
|
||||
unsafe { (*item).is_binary }
|
||||
}
|
||||
|
||||
// ── FffGrepMatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the relative path of the file containing this grep match.
|
||||
///
|
||||
/// Returns null if `m` is null. Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_relative_path(m: *const FffGrepMatch) -> *const c_char {
|
||||
if m.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*m).relative_path }
|
||||
}
|
||||
|
||||
/// Returns the file-name component of the file containing this grep match.
|
||||
///
|
||||
/// Returns null if `m` is null. Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_file_name(m: *const FffGrepMatch) -> *const c_char {
|
||||
if m.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*m).file_name }
|
||||
}
|
||||
|
||||
/// Returns the git status string for the matched file (e.g. `"M "`, `"??"`)
|
||||
/// or null if git is unavailable, the file is untracked, or `m` is null.
|
||||
///
|
||||
/// Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_git_status(m: *const FffGrepMatch) -> *const c_char {
|
||||
if m.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*m).git_status }
|
||||
}
|
||||
|
||||
/// Returns the full text content of the matched line.
|
||||
///
|
||||
/// Returns null if `m` is null. Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_line_content(m: *const FffGrepMatch) -> *const c_char {
|
||||
if m.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*m).line_content }
|
||||
}
|
||||
|
||||
/// Returns the 1-based line number of the match within its file.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_line_number(m: *const FffGrepMatch) -> u64 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).line_number }
|
||||
}
|
||||
|
||||
/// Returns the 0-based column of the match start within its line.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_col(m: *const FffGrepMatch) -> u32 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).col }
|
||||
}
|
||||
|
||||
/// Returns the byte offset of the match start from the beginning of the file.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_byte_offset(m: *const FffGrepMatch) -> u64 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).byte_offset }
|
||||
}
|
||||
|
||||
/// Returns the file size in bytes for the matched file. Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_size(m: *const FffGrepMatch) -> u64 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).size }
|
||||
}
|
||||
|
||||
/// Returns the combined frecency score for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_total_frecency_score(m: *const FffGrepMatch) -> i64 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).total_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the access-based frecency score for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_access_frecency_score(m: *const FffGrepMatch) -> i64 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).access_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the modification-based frecency score for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_modification_frecency_score(
|
||||
m: *const FffGrepMatch,
|
||||
) -> i64 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).modification_frecency_score }
|
||||
}
|
||||
|
||||
/// Returns the last-modified time as seconds since the UNIX epoch for the matched file.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_modified(m: *const FffGrepMatch) -> u64 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).modified }
|
||||
}
|
||||
|
||||
/// Returns the number of highlight ranges in this match. Returns `0` if `m` is null.
|
||||
///
|
||||
/// Use with [`fff_grep_match_get_match_range`] to iterate the highlight spans.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_match_ranges_count(m: *const FffGrepMatch) -> u32 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).match_ranges_count }
|
||||
}
|
||||
|
||||
/// Returns a pointer to the `index`-th [`FffMatchRange`] highlight span.
|
||||
///
|
||||
/// Returns null if `m` is null, `index >= match_ranges_count`, or the
|
||||
/// ranges array is null. The returned pointer is valid until the owning
|
||||
/// `FffGrepResult` is freed; do not free it directly.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_match_range(
|
||||
m: *const FffGrepMatch,
|
||||
index: u32,
|
||||
) -> *const FffMatchRange {
|
||||
if m.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
let m = unsafe { &*m };
|
||||
if index >= m.match_ranges_count || m.match_ranges.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { m.match_ranges.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Returns the number of context lines captured before the match.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// Use with [`fff_grep_match_get_context_before`] to read each line.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_context_before_count(m: *const FffGrepMatch) -> u32 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).context_before_count }
|
||||
}
|
||||
|
||||
/// Returns the `index`-th context line before the match.
|
||||
///
|
||||
/// Returns null if `m` is null, `index >= context_before_count`, or the
|
||||
/// context array is null. Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_context_before(
|
||||
m: *const FffGrepMatch,
|
||||
index: u32,
|
||||
) -> *const c_char {
|
||||
if m.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
let m = unsafe { &*m };
|
||||
if index >= m.context_before_count || m.context_before.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { *m.context_before.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Returns the number of context lines captured after the match.
|
||||
/// Returns `0` if `m` is null.
|
||||
///
|
||||
/// Use with [`fff_grep_match_get_context_after`] to read each line.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_context_after_count(m: *const FffGrepMatch) -> u32 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).context_after_count }
|
||||
}
|
||||
|
||||
/// Returns the `index`-th context line after the match.
|
||||
///
|
||||
/// Returns null if `m` is null, `index >= context_after_count`, or the
|
||||
/// context array is null. Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_context_after(
|
||||
m: *const FffGrepMatch,
|
||||
index: u32,
|
||||
) -> *const c_char {
|
||||
if m.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
let m = unsafe { &*m };
|
||||
if index >= m.context_after_count || m.context_after.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { *m.context_after.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Returns the fuzzy match score. Returns `0` if `m` is null or no fuzzy
|
||||
/// score is present.
|
||||
///
|
||||
/// Always check [`fff_grep_match_get_has_fuzzy_score`] first; `0` is
|
||||
/// ambiguous without that flag.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_fuzzy_score(m: *const FffGrepMatch) -> u16 {
|
||||
if m.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*m).fuzzy_score }
|
||||
}
|
||||
|
||||
/// Returns `true` if this match carries a valid fuzzy score.
|
||||
/// Returns `false` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_has_fuzzy_score(m: *const FffGrepMatch) -> bool {
|
||||
if m.is_null() {
|
||||
return false;
|
||||
}
|
||||
unsafe { (*m).has_fuzzy_score }
|
||||
}
|
||||
|
||||
/// Returns `true` if the match was identified as a symbol definition.
|
||||
/// Returns `false` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_is_definition(m: *const FffGrepMatch) -> bool {
|
||||
if m.is_null() {
|
||||
return false;
|
||||
}
|
||||
unsafe { (*m).is_definition }
|
||||
}
|
||||
|
||||
/// Returns `true` if the matched file was detected as binary.
|
||||
/// Returns `false` if `m` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `m` must be a valid `FffGrepMatch` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_match_get_is_binary(m: *const FffGrepMatch) -> bool {
|
||||
if m.is_null() {
|
||||
return false;
|
||||
}
|
||||
unsafe { (*m).is_binary }
|
||||
}
|
||||
|
||||
// ── FffSearchResult ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the number of items in the result. Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffSearchResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search_result_get_count(r: *const FffSearchResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).count }
|
||||
}
|
||||
|
||||
/// Returns the total number of files that matched before the result was
|
||||
/// truncated to the page size. Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffSearchResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search_result_get_total_matched(r: *const FffSearchResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).total_matched }
|
||||
}
|
||||
|
||||
/// Returns the total number of indexed files considered during search.
|
||||
/// Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffSearchResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search_result_get_total_files(r: *const FffSearchResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).total_files }
|
||||
}
|
||||
|
||||
// ── FffGrepResult ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the number of matches in the result. Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_result_get_count(r: *const FffGrepResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).count }
|
||||
}
|
||||
|
||||
/// Returns the total number of matches found across all pages.
|
||||
/// Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_result_get_total_matched(r: *const FffGrepResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).total_matched }
|
||||
}
|
||||
|
||||
/// Returns the number of files actually opened and searched in this call.
|
||||
/// Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_result_get_total_files_searched(r: *const FffGrepResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).total_files_searched }
|
||||
}
|
||||
|
||||
/// Returns the total number of indexed files before any filtering.
|
||||
/// Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_result_get_total_files(r: *const FffGrepResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).total_files }
|
||||
}
|
||||
|
||||
/// Returns the number of files eligible for search after path/type filtering.
|
||||
/// Returns `0` if `r` is null.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_result_get_filtered_file_count(r: *const FffGrepResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).filtered_file_count }
|
||||
}
|
||||
|
||||
/// Returns the file offset for the next page, or `0` if all files have been
|
||||
/// searched or `r` is null. Pass this value as `file_offset` to a subsequent
|
||||
/// `fff_live_grep` or `fff_multi_grep` call to continue pagination.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_result_get_next_file_offset(r: *const FffGrepResult) -> u32 {
|
||||
if r.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { (*r).next_file_offset }
|
||||
}
|
||||
|
||||
/// Returns the regex compilation error string if the engine fell back to
|
||||
/// literal matching, or null if there was no error or `r` is null.
|
||||
///
|
||||
/// Do not free the returned pointer.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `r` must be a valid `FffGrepResult` pointer or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_grep_result_get_regex_fallback_error(
|
||||
r: *const FffGrepResult,
|
||||
) -> *const c_char {
|
||||
if r.is_null() {
|
||||
return ptr::null();
|
||||
}
|
||||
unsafe { (*r).regex_fallback_error }
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::CString;
|
||||
use std::ptr;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_file_item(path: &str, name: &str) -> FffFileItem {
|
||||
FffFileItem {
|
||||
relative_path: CString::new(path).unwrap().into_raw(),
|
||||
file_name: CString::new(name).unwrap().into_raw(),
|
||||
git_status: ptr::null_mut(),
|
||||
size: 1024,
|
||||
modified: 1_700_000_000,
|
||||
access_frecency_score: 10,
|
||||
modification_frecency_score: 20,
|
||||
total_frecency_score: 30,
|
||||
is_binary: false,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn free_file_item(item: &mut FffFileItem) {
|
||||
unsafe {
|
||||
if !item.relative_path.is_null() {
|
||||
drop(CString::from_raw(item.relative_path));
|
||||
}
|
||||
if !item.file_name.is_null() {
|
||||
drop(CString::from_raw(item.file_name));
|
||||
}
|
||||
if !item.git_status.is_null() {
|
||||
drop(CString::from_raw(item.git_status));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_grep_match(path: &str, line: &str) -> FffGrepMatch {
|
||||
FffGrepMatch {
|
||||
relative_path: CString::new(path).unwrap().into_raw(),
|
||||
file_name: CString::new("file.rs").unwrap().into_raw(),
|
||||
git_status: ptr::null_mut(),
|
||||
line_content: CString::new(line).unwrap().into_raw(),
|
||||
match_ranges: ptr::null_mut(),
|
||||
context_before: ptr::null_mut(),
|
||||
context_after: ptr::null_mut(),
|
||||
size: 512,
|
||||
modified: 1_600_000_000,
|
||||
total_frecency_score: 5,
|
||||
access_frecency_score: 6,
|
||||
modification_frecency_score: 7,
|
||||
line_number: 42,
|
||||
byte_offset: 100,
|
||||
col: 8,
|
||||
match_ranges_count: 0,
|
||||
context_before_count: 0,
|
||||
context_after_count: 0,
|
||||
fuzzy_score: 0,
|
||||
has_fuzzy_score: false,
|
||||
is_binary: false,
|
||||
is_definition: true,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn free_grep_match(m: &mut FffGrepMatch) {
|
||||
unsafe {
|
||||
if !m.relative_path.is_null() {
|
||||
drop(CString::from_raw(m.relative_path));
|
||||
}
|
||||
if !m.file_name.is_null() {
|
||||
drop(CString::from_raw(m.file_name));
|
||||
}
|
||||
if !m.line_content.is_null() {
|
||||
drop(CString::from_raw(m.line_content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_search_result(count: u32, total: u32, files: u32) -> FffSearchResult {
|
||||
FffSearchResult {
|
||||
items: ptr::null_mut(),
|
||||
scores: ptr::null_mut(),
|
||||
count,
|
||||
total_matched: total,
|
||||
total_files: files,
|
||||
location: crate::ffi_types::FffLocation {
|
||||
tag: 0,
|
||||
line: 0,
|
||||
col: 0,
|
||||
end_line: 0,
|
||||
end_col: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_grep_result() -> FffGrepResult {
|
||||
FffGrepResult {
|
||||
items: ptr::null_mut(),
|
||||
count: 3,
|
||||
total_matched: 10,
|
||||
total_files_searched: 50,
|
||||
total_files: 200,
|
||||
filtered_file_count: 80,
|
||||
next_file_offset: 51,
|
||||
regex_fallback_error: ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── null-guard tests: every function returns its zero-value on NULL ───────
|
||||
|
||||
#[test]
|
||||
fn null_file_item_returns_null_or_zero() {
|
||||
let null: *const FffFileItem = ptr::null();
|
||||
unsafe {
|
||||
assert!(fff_file_item_get_relative_path(null).is_null());
|
||||
assert!(fff_file_item_get_file_name(null).is_null());
|
||||
assert!(fff_file_item_get_git_status(null).is_null());
|
||||
assert_eq!(fff_file_item_get_size(null), 0);
|
||||
assert_eq!(fff_file_item_get_modified(null), 0);
|
||||
assert_eq!(fff_file_item_get_access_frecency_score(null), 0);
|
||||
assert_eq!(fff_file_item_get_modification_frecency_score(null), 0);
|
||||
assert_eq!(fff_file_item_get_total_frecency_score(null), 0);
|
||||
assert!(!fff_file_item_get_is_binary(null));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_grep_match_returns_null_or_zero() {
|
||||
let null: *const FffGrepMatch = ptr::null();
|
||||
unsafe {
|
||||
assert!(fff_grep_match_get_relative_path(null).is_null());
|
||||
assert!(fff_grep_match_get_file_name(null).is_null());
|
||||
assert!(fff_grep_match_get_git_status(null).is_null());
|
||||
assert!(fff_grep_match_get_line_content(null).is_null());
|
||||
assert_eq!(fff_grep_match_get_line_number(null), 0);
|
||||
assert_eq!(fff_grep_match_get_byte_offset(null), 0);
|
||||
assert_eq!(fff_grep_match_get_col(null), 0);
|
||||
assert_eq!(fff_grep_match_get_size(null), 0);
|
||||
assert_eq!(fff_grep_match_get_modified(null), 0);
|
||||
assert_eq!(fff_grep_match_get_total_frecency_score(null), 0);
|
||||
assert_eq!(fff_grep_match_get_access_frecency_score(null), 0);
|
||||
assert_eq!(fff_grep_match_get_modification_frecency_score(null), 0);
|
||||
assert_eq!(fff_grep_match_get_match_ranges_count(null), 0);
|
||||
assert_eq!(fff_grep_match_get_context_before_count(null), 0);
|
||||
assert_eq!(fff_grep_match_get_context_after_count(null), 0);
|
||||
assert!(!fff_grep_match_get_has_fuzzy_score(null));
|
||||
assert_eq!(fff_grep_match_get_fuzzy_score(null), 0);
|
||||
assert!(!fff_grep_match_get_is_binary(null));
|
||||
assert!(!fff_grep_match_get_is_definition(null));
|
||||
assert!(fff_grep_match_get_context_before(null, 0).is_null());
|
||||
assert!(fff_grep_match_get_context_after(null, 0).is_null());
|
||||
assert!(fff_grep_match_get_match_range(null, 0).is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_search_result_returns_zero() {
|
||||
let null: *const FffSearchResult = ptr::null();
|
||||
unsafe {
|
||||
assert_eq!(fff_search_result_get_count(null), 0);
|
||||
assert_eq!(fff_search_result_get_total_matched(null), 0);
|
||||
assert_eq!(fff_search_result_get_total_files(null), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_grep_result_returns_zero_or_null() {
|
||||
let null: *const FffGrepResult = ptr::null();
|
||||
unsafe {
|
||||
assert_eq!(fff_grep_result_get_count(null), 0);
|
||||
assert_eq!(fff_grep_result_get_total_matched(null), 0);
|
||||
assert_eq!(fff_grep_result_get_total_files_searched(null), 0);
|
||||
assert_eq!(fff_grep_result_get_total_files(null), 0);
|
||||
assert_eq!(fff_grep_result_get_filtered_file_count(null), 0);
|
||||
assert_eq!(fff_grep_result_get_next_file_offset(null), 0);
|
||||
assert!(fff_grep_result_get_regex_fallback_error(null).is_null());
|
||||
}
|
||||
}
|
||||
|
||||
// ── data correctness tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn file_item_getters_return_correct_values() {
|
||||
let mut item = make_file_item("src/main.rs", "main.rs");
|
||||
let p = &item as *const FffFileItem;
|
||||
unsafe {
|
||||
let path = std::ffi::CStr::from_ptr(fff_file_item_get_relative_path(p));
|
||||
assert_eq!(path.to_str().unwrap(), "src/main.rs");
|
||||
|
||||
let name = std::ffi::CStr::from_ptr(fff_file_item_get_file_name(p));
|
||||
assert_eq!(name.to_str().unwrap(), "main.rs");
|
||||
|
||||
assert!(fff_file_item_get_git_status(p).is_null());
|
||||
assert_eq!(fff_file_item_get_size(p), 1024);
|
||||
assert_eq!(fff_file_item_get_modified(p), 1_700_000_000);
|
||||
assert_eq!(fff_file_item_get_access_frecency_score(p), 10);
|
||||
assert_eq!(fff_file_item_get_modification_frecency_score(p), 20);
|
||||
assert_eq!(fff_file_item_get_total_frecency_score(p), 30);
|
||||
assert!(!fff_file_item_get_is_binary(p));
|
||||
|
||||
free_file_item(&mut item);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_match_getters_return_correct_values() {
|
||||
let mut m = make_grep_match("src/lib.rs", "fn hello()");
|
||||
let p = &m as *const FffGrepMatch;
|
||||
unsafe {
|
||||
let path = std::ffi::CStr::from_ptr(fff_grep_match_get_relative_path(p));
|
||||
assert_eq!(path.to_str().unwrap(), "src/lib.rs");
|
||||
|
||||
let line = std::ffi::CStr::from_ptr(fff_grep_match_get_line_content(p));
|
||||
assert_eq!(line.to_str().unwrap(), "fn hello()");
|
||||
|
||||
assert_eq!(fff_grep_match_get_line_number(p), 42);
|
||||
assert_eq!(fff_grep_match_get_byte_offset(p), 100);
|
||||
assert_eq!(fff_grep_match_get_col(p), 8);
|
||||
assert_eq!(fff_grep_match_get_size(p), 512);
|
||||
assert_eq!(fff_grep_match_get_modified(p), 1_600_000_000);
|
||||
assert_eq!(fff_grep_match_get_total_frecency_score(p), 5);
|
||||
assert_eq!(fff_grep_match_get_access_frecency_score(p), 6);
|
||||
assert_eq!(fff_grep_match_get_modification_frecency_score(p), 7);
|
||||
assert_eq!(fff_grep_match_get_match_ranges_count(p), 0);
|
||||
assert!(!fff_grep_match_get_has_fuzzy_score(p));
|
||||
assert!(!fff_grep_match_get_is_binary(p));
|
||||
assert!(fff_grep_match_get_is_definition(p));
|
||||
|
||||
free_grep_match(&mut m);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_result_getters_return_correct_values() {
|
||||
let r = make_search_result(5, 20, 100);
|
||||
let p = &r as *const FffSearchResult;
|
||||
unsafe {
|
||||
assert_eq!(fff_search_result_get_count(p), 5);
|
||||
assert_eq!(fff_search_result_get_total_matched(p), 20);
|
||||
assert_eq!(fff_search_result_get_total_files(p), 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_result_getters_return_correct_values() {
|
||||
let r = make_grep_result();
|
||||
let p = &r as *const FffGrepResult;
|
||||
unsafe {
|
||||
assert_eq!(fff_grep_result_get_count(p), 3);
|
||||
assert_eq!(fff_grep_result_get_total_matched(p), 10);
|
||||
assert_eq!(fff_grep_result_get_total_files_searched(p), 50);
|
||||
assert_eq!(fff_grep_result_get_total_files(p), 200);
|
||||
assert_eq!(fff_grep_result_get_filtered_file_count(p), 80);
|
||||
assert_eq!(fff_grep_result_get_next_file_offset(p), 51);
|
||||
assert!(fff_grep_result_get_regex_fallback_error(p).is_null());
|
||||
}
|
||||
}
|
||||
}
|
||||
+216
-26
@@ -7,12 +7,12 @@
|
||||
use std::ffi::{CString, c_char, c_void};
|
||||
use std::ptr;
|
||||
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::git::format_git_status;
|
||||
use fff::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
use fff::{
|
||||
DirItem, DirSearchResult, FileItem, GrepMatch, GrepResult, Location, MixedItemRef,
|
||||
MixedSearchResult, Score, SearchResult,
|
||||
};
|
||||
|
||||
/// Allocate a heap CString from a `&str`, returning a raw pointer.
|
||||
fn cstring_new(s: &str) -> *mut c_char {
|
||||
@@ -64,7 +64,6 @@ unsafe fn free_cstring_array(arr: *mut *mut c_char, count: u32) {
|
||||
/// Free the entire result with `fff_free_search_result`.
|
||||
#[repr(C)]
|
||||
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,
|
||||
@@ -76,12 +75,11 @@ pub struct FffFileItem {
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
impl From<&FileItem> for FffFileItem {
|
||||
fn from(item: &FileItem) -> Self {
|
||||
impl FffFileItem {
|
||||
pub fn from_item(item: &FileItem, picker: &FilePicker) -> Self {
|
||||
FffFileItem {
|
||||
path: cstring_new(item.path_str()),
|
||||
relative_path: cstring_new(item.relative_path()),
|
||||
file_name: cstring_new(item.file_name()),
|
||||
relative_path: cstring_new(&item.relative_path(picker)),
|
||||
file_name: cstring_new(&item.file_name(picker)),
|
||||
git_status: cstring_new(format_git_status(item.git_status)),
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
@@ -98,9 +96,6 @@ impl FffFileItem {
|
||||
/// 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));
|
||||
}
|
||||
@@ -125,6 +120,7 @@ pub struct FffScore {
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
pub path_alignment_bonus: i32,
|
||||
pub exact_match: bool,
|
||||
pub match_type: *mut c_char,
|
||||
}
|
||||
@@ -140,6 +136,7 @@ impl From<&Score> for FffScore {
|
||||
distance_penalty: score.distance_penalty,
|
||||
current_file_penalty: score.current_file_penalty,
|
||||
combo_match_boost: score.combo_match_boost,
|
||||
path_alignment_bonus: score.path_alignment_bonus,
|
||||
exact_match: score.exact_match,
|
||||
match_type: cstring_new(score.match_type),
|
||||
}
|
||||
@@ -230,8 +227,12 @@ pub struct FffSearchResult {
|
||||
|
||||
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();
|
||||
pub fn from_core(result: &SearchResult, picker: &FilePicker) -> *mut Self {
|
||||
let items: Vec<FffFileItem> = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| FffFileItem::from_item(i, picker))
|
||||
.collect();
|
||||
let scores: Vec<FffScore> = result.scores.iter().map(FffScore::from).collect();
|
||||
let count = items.len() as u32;
|
||||
|
||||
@@ -267,7 +268,6 @@ pub struct FffMatchRange {
|
||||
#[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,
|
||||
@@ -297,7 +297,7 @@ pub struct FffGrepMatch {
|
||||
}
|
||||
|
||||
impl FffGrepMatch {
|
||||
fn from_core_with_file(m: &GrepMatch, file: &FileItem) -> Self {
|
||||
fn from_core_with_file(m: &GrepMatch, file: &FileItem, picker: &FilePicker) -> Self {
|
||||
let ranges: Vec<FffMatchRange> = m
|
||||
.match_byte_offsets
|
||||
.iter()
|
||||
@@ -312,9 +312,8 @@ impl FffGrepMatch {
|
||||
};
|
||||
|
||||
FffGrepMatch {
|
||||
path: cstring_new(file.path_str()),
|
||||
relative_path: cstring_new(file.relative_path()),
|
||||
file_name: cstring_new(file.file_name()),
|
||||
relative_path: cstring_new(&file.relative_path(picker)),
|
||||
file_name: cstring_new(&file.file_name(picker)),
|
||||
git_status: cstring_new(format_git_status(file.git_status)),
|
||||
line_content: cstring_new(&m.line_content),
|
||||
match_ranges,
|
||||
@@ -342,9 +341,6 @@ impl FffGrepMatch {
|
||||
/// 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));
|
||||
}
|
||||
@@ -395,13 +391,13 @@ pub struct FffGrepResult {
|
||||
|
||||
impl FffGrepResult {
|
||||
/// Convert a core `GrepResult` into a heap-allocated `FffGrepResult`.
|
||||
pub fn from_core(result: &GrepResult) -> *mut Self {
|
||||
pub fn from_core(result: &GrepResult, picker: &FilePicker) -> *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)
|
||||
FffGrepMatch::from_core_with_file(m, file, picker)
|
||||
})
|
||||
.collect();
|
||||
let (items_ptr, count) = vec_to_raw(items);
|
||||
@@ -515,6 +511,200 @@ impl FffResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory item returned by `fff_search_directories`.
|
||||
///
|
||||
/// All string fields are heap-allocated and owned by the parent `FffDirSearchResult`.
|
||||
/// Free the entire result with `fff_free_dir_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffDirItem {
|
||||
pub relative_path: *mut c_char,
|
||||
pub dir_name: *mut c_char,
|
||||
pub max_access_frecency: i32,
|
||||
}
|
||||
|
||||
impl FffDirItem {
|
||||
pub fn from_item(item: &DirItem, picker: &FilePicker) -> Self {
|
||||
FffDirItem {
|
||||
relative_path: cstring_new(&item.relative_path(picker)),
|
||||
dir_name: cstring_new(&item.dir_name(picker)),
|
||||
max_access_frecency: item.max_access_frecency(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ## Safety
|
||||
/// All string pointers must have been allocated by the rust side
|
||||
pub unsafe fn free_strings(&mut self) {
|
||||
unsafe {
|
||||
if !self.relative_path.is_null() {
|
||||
drop(CString::from_raw(self.relative_path));
|
||||
}
|
||||
if !self.dir_name.is_null() {
|
||||
drop(CString::from_raw(self.dir_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory search result returned by `fff_search_directories`.
|
||||
///
|
||||
/// The caller must free this with `fff_free_dir_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffDirSearchResult {
|
||||
/// Pointer to a heap-allocated array of `FffDirItem` (length = `count`).
|
||||
pub items: *mut FffDirItem,
|
||||
/// 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 directories that matched the query.
|
||||
pub total_matched: u32,
|
||||
/// Total number of indexed directories.
|
||||
pub total_dirs: u32,
|
||||
}
|
||||
|
||||
impl FffDirSearchResult {
|
||||
/// Convert a core `DirSearchResult` into a heap-allocated `FffDirSearchResult`.
|
||||
pub fn from_core(result: &DirSearchResult, picker: &FilePicker) -> *mut Self {
|
||||
let items: Vec<FffDirItem> = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| FffDirItem::from_item(i, picker))
|
||||
.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(FffDirSearchResult {
|
||||
items: items_ptr,
|
||||
scores: scores_ptr,
|
||||
count,
|
||||
total_matched: result.total_matched as u32,
|
||||
total_dirs: result.total_dirs as u32,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A single item in a mixed (files + directories) search result.
|
||||
///
|
||||
/// `item_type`: 0 = file, 1 = directory.
|
||||
/// All string fields are heap-allocated and owned by the parent `FffMixedSearchResult`.
|
||||
#[repr(C)]
|
||||
pub struct FffMixedItem {
|
||||
/// 0 = file, 1 = directory.
|
||||
pub item_type: u8,
|
||||
pub relative_path: *mut c_char,
|
||||
/// Filename for files, last directory segment for directories.
|
||||
pub display_name: *mut c_char,
|
||||
pub git_status: *mut c_char,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
/// The access frecency score for files, or max access frecency among all the immediate
|
||||
/// children for directories.
|
||||
pub access_frecency_score: i64,
|
||||
/// Always 0 for directories
|
||||
pub modification_frecency_score: i64,
|
||||
/// Always 0 for directories
|
||||
pub total_frecency_score: i64,
|
||||
/// Always 0 for directories
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
impl FffMixedItem {
|
||||
pub fn from_mixed_ref(item: &MixedItemRef<'_>, picker: &FilePicker) -> Self {
|
||||
match item {
|
||||
MixedItemRef::File(file) => FffMixedItem {
|
||||
item_type: 0,
|
||||
relative_path: cstring_new(&file.relative_path(picker)),
|
||||
display_name: cstring_new(&file.file_name(picker)),
|
||||
git_status: cstring_new(format_git_status(file.git_status)),
|
||||
size: file.size,
|
||||
modified: file.modified,
|
||||
access_frecency_score: file.access_frecency_score as i64,
|
||||
modification_frecency_score: file.modification_frecency_score as i64,
|
||||
total_frecency_score: file.total_frecency_score() as i64,
|
||||
is_binary: file.is_binary(),
|
||||
},
|
||||
MixedItemRef::Dir(dir) => FffMixedItem {
|
||||
item_type: 1,
|
||||
relative_path: cstring_new(&dir.relative_path(picker)),
|
||||
display_name: cstring_new(&dir.dir_name(picker)),
|
||||
git_status: cstring_new(""),
|
||||
size: 0,
|
||||
modified: 0,
|
||||
access_frecency_score: dir.max_access_frecency() as i64,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: dir.max_access_frecency() as i64,
|
||||
is_binary: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// ## Safety
|
||||
/// All string pointers must have been allocated by rust side
|
||||
pub unsafe fn free_strings(&mut self) {
|
||||
unsafe {
|
||||
if !self.relative_path.is_null() {
|
||||
drop(CString::from_raw(self.relative_path));
|
||||
}
|
||||
if !self.display_name.is_null() {
|
||||
drop(CString::from_raw(self.display_name));
|
||||
}
|
||||
if !self.git_status.is_null() {
|
||||
drop(CString::from_raw(self.git_status));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixed search result returned by `fff_search_mixed`.
|
||||
///
|
||||
/// The caller must free this with `fff_free_mixed_search_result`.
|
||||
#[repr(C)]
|
||||
pub struct FffMixedSearchResult {
|
||||
/// Pointer to a heap-allocated array of `FffMixedItem` (length = `count`).
|
||||
pub items: *mut FffMixedItem,
|
||||
/// 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 items (files + dirs) that matched the query.
|
||||
pub total_matched: u32,
|
||||
/// Total number of indexed files.
|
||||
pub total_files: u32,
|
||||
/// Total number of indexed directories.
|
||||
pub total_dirs: u32,
|
||||
/// Location parsed from the query string.
|
||||
pub location: FffLocation,
|
||||
}
|
||||
|
||||
impl FffMixedSearchResult {
|
||||
/// Convert a core `MixedSearchResult` into a heap-allocated `FffMixedSearchResult`.
|
||||
pub fn from_core(result: &MixedSearchResult, picker: &FilePicker) -> *mut Self {
|
||||
let items: Vec<FffMixedItem> = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| FffMixedItem::from_mixed_ref(i, picker))
|
||||
.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(FffMixedSearchResult {
|
||||
items: items_ptr,
|
||||
scores: scores_ptr,
|
||||
count,
|
||||
total_matched: result.total_matched as u32,
|
||||
total_files: result.total_files as u32,
|
||||
total_dirs: result.total_dirs as u32,
|
||||
location: FffLocation::from(result.location.as_ref()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan progress returned by `fff_get_scan_progress`.
|
||||
/// The caller must free this with `fff_free_scan_progress`.
|
||||
#[repr(C)]
|
||||
|
||||
+455
-51
@@ -28,15 +28,17 @@ use std::time::Duration;
|
||||
|
||||
use fff::shared::SharedQueryTracker;
|
||||
|
||||
mod accessors;
|
||||
mod ffi_types;
|
||||
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
use fff::query_tracker::QueryTracker;
|
||||
use fff::{DbHealthChecker, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff::{SharedFrecency, SharedPicker};
|
||||
use fff::{SharedFilePicker, SharedFrecency};
|
||||
use ffi_types::{
|
||||
FffFileItem, FffGrepMatch, FffGrepResult, FffResult, FffScanProgress, FffScore, FffSearchResult,
|
||||
FffDirItem, FffDirSearchResult, FffFileItem, FffGrepMatch, FffGrepResult, FffMixedItem,
|
||||
FffMixedSearchResult, FffResult, FffScanProgress, FffScore, FffSearchResult,
|
||||
};
|
||||
|
||||
/// Opaque fff_handle holding all per-instance state.
|
||||
@@ -44,7 +46,7 @@ use ffi_types::{
|
||||
/// The caller receives this as `*mut c_void` and must pass it to every FFI call.
|
||||
/// The fff_handle is freed by `fff_destroy`.
|
||||
struct FffInstance {
|
||||
picker: SharedPicker,
|
||||
picker: SharedFilePicker,
|
||||
frecency: SharedFrecency,
|
||||
query_tracker: SharedQueryTracker,
|
||||
}
|
||||
@@ -102,41 +104,114 @@ fn default_i32(val: i32, default: i32) -> i32 {
|
||||
if val == 0 { default } else { val }
|
||||
}
|
||||
|
||||
/// Create a new file finder instance.
|
||||
/// Create a new file finder instance (legacy signature).
|
||||
///
|
||||
/// @deprecated prefer `fff_create_instance2`, which also exposes log file and
|
||||
/// cache-budget configuration. This function delegates to `fff_create_instance2`
|
||||
/// with NULL log paths and auto cache budget, so behaviour is unchanged.
|
||||
///
|
||||
/// The `use_unsafe_no_lock` parameter is deprecated and ignored; see
|
||||
/// [`fff_create_instance2`] for details.
|
||||
///
|
||||
/// ## Safety
|
||||
/// See `fff_create_instance2`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_create_instance(
|
||||
base_path: *const c_char,
|
||||
frecency_db_path: *const c_char,
|
||||
history_db_path: *const c_char,
|
||||
_use_unsafe_no_lock: bool,
|
||||
enable_mmap_cache: bool,
|
||||
enable_content_indexing: bool,
|
||||
watch: bool,
|
||||
ai_mode: bool,
|
||||
) -> *mut FffResult {
|
||||
unsafe {
|
||||
fff_create_instance2(
|
||||
base_path,
|
||||
frecency_db_path,
|
||||
history_db_path,
|
||||
false,
|
||||
enable_mmap_cache,
|
||||
enable_content_indexing,
|
||||
watch,
|
||||
ai_mode,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new file finder instance (v2, with full options).
|
||||
///
|
||||
/// 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)
|
||||
/// * `base_path` – directory to index (required)
|
||||
/// * `frecency_db_path` – frecency LMDB database path (NULL/empty to skip)
|
||||
/// * `history_db_path` – query history LMDB database path (NULL/empty to skip)
|
||||
/// * `use_unsafe_no_lock` – **deprecated, ignored.** Previously enabled
|
||||
/// `MDB_NOLOCK|MDB_NOSYNC|MDB_NOMETASYNC` for LMDB; benchmarks showed no
|
||||
/// measurable win under realistic contention, so the flag is now a no-op.
|
||||
/// The parameter remains in the signature for ABI compatibility and will be
|
||||
/// removed in a future release.
|
||||
/// * `enable_mmap_cache` – pre-populate mmap caches after the initial scan
|
||||
/// * `enable_content_indexing` – build content index after the initial scan
|
||||
/// * `watch` – start a background file-system watcher for live updates
|
||||
/// * `ai_mode` – enable AI-agent optimizations
|
||||
/// * `log_file_path` – tracing log file path (NULL/empty to skip).
|
||||
/// Only the first successful call in a process installs the subscriber;
|
||||
/// subsequent calls are no-ops at the log layer.
|
||||
/// * `log_level` – `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`
|
||||
/// (NULL/empty defaults to `"info"`). Ignored when `log_file_path` is not set.
|
||||
/// * `cache_budget_max_files` – content cache file-count cap (0 = auto)
|
||||
/// * `cache_budget_max_bytes` – content cache byte cap (0 = auto)
|
||||
/// * `cache_budget_max_file_size` – per-file byte cap (0 = auto)
|
||||
///
|
||||
/// When all three `cache_budget_*` values are 0 the budget is auto-computed
|
||||
/// from repo size after the initial scan. Otherwise an explicit budget is
|
||||
/// used: any field left at 0 falls back to its `unlimited()` default.
|
||||
///
|
||||
/// ## Safety
|
||||
/// String parameters must be valid null-terminated UTF-8 or NULL.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_create_instance(
|
||||
pub unsafe extern "C" fn fff_create_instance2(
|
||||
base_path: *const c_char,
|
||||
frecency_db_path: *const c_char,
|
||||
history_db_path: *const c_char,
|
||||
use_unsafe_no_lock: bool,
|
||||
warmup_mmap_cache: bool,
|
||||
_use_unsafe_no_lock: bool,
|
||||
enable_mmap_cache: bool,
|
||||
enable_content_indexing: bool,
|
||||
watch: bool,
|
||||
ai_mode: bool,
|
||||
log_file_path: *const c_char,
|
||||
log_level: *const c_char,
|
||||
cache_budget_max_files: u64,
|
||||
cache_budget_max_bytes: u64,
|
||||
cache_budget_max_file_size: u64,
|
||||
) -> *mut FffResult {
|
||||
let base_path_str = match unsafe { cstr_to_str(base_path) } {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => return FffResult::err("base_path is null or empty"),
|
||||
};
|
||||
|
||||
if let Some(log_path) = unsafe { optional_cstr(log_file_path) } {
|
||||
let level = unsafe { optional_cstr(log_level) };
|
||||
if let Err(e) = fff::log::init_tracing(log_path, level) {
|
||||
return FffResult::err(&format!("Failed to init tracing: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
let frecency_path = unsafe { optional_cstr(frecency_db_path) }.map(|s| s.to_string());
|
||||
let history_path = unsafe { optional_cstr(history_db_path) }.map(|s| s.to_string());
|
||||
|
||||
// Create shared state that background threads will write into.
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
let query_tracker = SharedQueryTracker::default();
|
||||
|
||||
@@ -146,12 +221,12 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match FrecencyTracker::new(frecency_path, use_unsafe_no_lock) {
|
||||
match FrecencyTracker::open(frecency_path) {
|
||||
Ok(tracker) => {
|
||||
if let Err(e) = shared_frecency.init(tracker) {
|
||||
return FffResult::err(&format!("Failed to acquire frecency lock: {}", e));
|
||||
}
|
||||
let _ = shared_frecency.spawn_gc(frecency_path.clone(), use_unsafe_no_lock);
|
||||
let _ = shared_frecency.spawn_gc(frecency_path.clone());
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
|
||||
}
|
||||
@@ -163,7 +238,7 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match QueryTracker::new(history_path, use_unsafe_no_lock) {
|
||||
match QueryTracker::open(history_path) {
|
||||
Ok(tracker) => {
|
||||
if let Err(e) = query_tracker.init(tracker) {
|
||||
return FffResult::err(&format!("Failed to acquire query tracker lock: {}", e));
|
||||
@@ -179,16 +254,23 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
FFFMode::Neovim
|
||||
};
|
||||
|
||||
let cache_budget = fff::ContentCacheBudget::from_overrides(
|
||||
cache_budget_max_files as usize,
|
||||
cache_budget_max_bytes,
|
||||
cache_budget_max_file_size,
|
||||
);
|
||||
|
||||
// Initialize file picker (writes directly into shared_picker)
|
||||
if let Err(e) = FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path_str,
|
||||
warmup_mmap_cache,
|
||||
enable_mmap_cache,
|
||||
enable_content_indexing,
|
||||
watch,
|
||||
mode,
|
||||
cache_budget: None,
|
||||
..Default::default()
|
||||
cache_budget,
|
||||
},
|
||||
) {
|
||||
return FffResult::err(&format!("Failed to init file picker: {}", e));
|
||||
@@ -294,8 +376,7 @@ pub unsafe extern "C" fn fff_search(
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query_str);
|
||||
|
||||
let results = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
let results = picker.fuzzy_search(
|
||||
&parsed,
|
||||
query_tracker_ref,
|
||||
FuzzySearchOptions {
|
||||
@@ -311,10 +392,167 @@ pub unsafe extern "C" fn fff_search(
|
||||
},
|
||||
);
|
||||
|
||||
let search_result = FffSearchResult::from_core(&results);
|
||||
let search_result = FffSearchResult::from_core(&results, picker);
|
||||
FffResult::ok_handle(search_result as *mut c_void)
|
||||
}
|
||||
|
||||
/// Perform fuzzy search on indexed directories.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` – instance from `fff_create_instance`
|
||||
/// * `query` – search query string
|
||||
/// * `current_file` – path of the currently open file for distance scoring (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)
|
||||
///
|
||||
/// ## 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.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search_directories(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
current_file: *const c_char,
|
||||
max_threads: u32,
|
||||
page_index: u32,
|
||||
page_size: u32,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let current_file_str = unsafe { optional_cstr(current_file) };
|
||||
let page_size = default_u32(page_size, 100) as usize;
|
||||
|
||||
let picker_guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return FffResult::err("File picker not initialized. Call fff_create_instance first.");
|
||||
}
|
||||
};
|
||||
|
||||
let parser = QueryParser::new(fff_query_parser::DirSearchConfig);
|
||||
let parsed = parser.parse(query_str);
|
||||
|
||||
let results = picker.fuzzy_search_directories(
|
||||
&parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads: max_threads as usize,
|
||||
current_file: current_file_str,
|
||||
project_path: Some(picker.base_path()),
|
||||
combo_boost_score_multiplier: 0,
|
||||
min_combo_count: 0,
|
||||
pagination: PaginationArgs {
|
||||
offset: page_index as usize,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let dir_result = FffDirSearchResult::from_core(&results, picker);
|
||||
FffResult::ok_handle(dir_result as *mut c_void)
|
||||
}
|
||||
|
||||
/// Perform a mixed fuzzy search across both files and directories.
|
||||
///
|
||||
/// Returns a single flat list where files and directories are interleaved
|
||||
/// by total score in descending order. Each item has an `item_type` field
|
||||
/// (0 = file, 1 = directory).
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `fff_handle` – instance from `fff_create_instance`
|
||||
/// * `query` – search query string
|
||||
/// * `current_file` – path of the currently open file (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.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search_mixed(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
current_file: *const c_char,
|
||||
max_threads: u32,
|
||||
page_index: u32,
|
||||
page_size: u32,
|
||||
combo_boost_multiplier: i32,
|
||||
min_combo_count: u32,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let current_file_str = unsafe { optional_cstr(current_file) };
|
||||
let page_size = default_u32(page_size, 100) as usize;
|
||||
let min_combo_count = default_u32(min_combo_count, 3);
|
||||
let combo_boost_multiplier = default_i32(combo_boost_multiplier, 100);
|
||||
|
||||
let picker_guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return FffResult::err("File picker not initialized. Call fff_create_instance first.");
|
||||
}
|
||||
};
|
||||
|
||||
let qt_guard = match inst.query_tracker.read() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::err("Failed to acquire query tracker lock"),
|
||||
};
|
||||
let query_tracker_ref = qt_guard.as_ref();
|
||||
|
||||
let parser = QueryParser::new(fff_query_parser::MixedSearchConfig);
|
||||
let parsed = parser.parse(query_str);
|
||||
|
||||
let results = picker.fuzzy_search_mixed(
|
||||
&parsed,
|
||||
query_tracker_ref,
|
||||
FuzzySearchOptions {
|
||||
max_threads: max_threads as usize,
|
||||
current_file: current_file_str,
|
||||
project_path: Some(picker.base_path()),
|
||||
combo_boost_score_multiplier: combo_boost_multiplier,
|
||||
min_combo_count,
|
||||
pagination: PaginationArgs {
|
||||
offset: page_index as usize,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let mixed_result = FffMixedSearchResult::from_core(&results, picker);
|
||||
FffResult::ok_handle(mixed_result as *mut c_void)
|
||||
}
|
||||
|
||||
/// Perform content search (grep) across indexed files.
|
||||
///
|
||||
/// # Parameters
|
||||
@@ -390,10 +628,12 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
before_context: before_context as usize,
|
||||
after_context: after_context as usize,
|
||||
classify_definitions,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
|
||||
let result = picker.grep(&parsed, &options);
|
||||
let grep_result = FffGrepResult::from_core(&result);
|
||||
let grep_result = FffGrepResult::from_core(&result, picker);
|
||||
FffResult::ok_handle(grep_result as *mut c_void)
|
||||
}
|
||||
|
||||
@@ -491,17 +731,12 @@ pub unsafe extern "C" fn fff_multi_grep(
|
||||
before_context: before_context as usize,
|
||||
after_context: after_context as usize,
|
||||
classify_definitions,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
|
||||
let result = fff::multi_grep_search(
|
||||
picker.get_files(),
|
||||
&patterns,
|
||||
constraint_refs,
|
||||
&options,
|
||||
picker.cache_budget(),
|
||||
None,
|
||||
);
|
||||
let grep_result = FffGrepResult::from_core(&result);
|
||||
let result = picker.multi_grep(&patterns, constraint_refs, &options);
|
||||
let grep_result = FffGrepResult::from_core(&result, picker);
|
||||
FffResult::ok_handle(grep_result as *mut c_void)
|
||||
}
|
||||
|
||||
@@ -516,18 +751,10 @@ pub unsafe extern "C" fn fff_scan_files(fff_handle: *mut c_void) -> *mut FffResu
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let mut guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match guard.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
match picker.trigger_rescan(&inst.frecency) {
|
||||
Ok(_) => FffResult::ok_empty(),
|
||||
// Async: rescan runs on a BG thread, caller returns immediately.
|
||||
// Use `fff_is_scanning` / `fff_wait_for_scan` to observe progress.
|
||||
match inst.picker.trigger_full_rescan_async(&inst.frecency) {
|
||||
Ok(()) => FffResult::ok_empty(),
|
||||
Err(e) => FffResult::err(&format!("Failed to trigger rescan: {}", e)),
|
||||
}
|
||||
}
|
||||
@@ -550,6 +777,33 @@ pub unsafe extern "C" fn fff_is_scanning(fff_handle: *mut c_void) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the base path of the file picker.
|
||||
///
|
||||
/// Returns an `FffResult` with a heap-allocated C string in the `handle`
|
||||
/// field. Free the string with `fff_free_string` after reading it.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_get_base_path(fff_handle: *mut c_void) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
FffResult::ok_string(&picker.base_path().to_string_lossy())
|
||||
}
|
||||
|
||||
/// Get scan progress information.
|
||||
///
|
||||
/// ## Safety
|
||||
@@ -648,13 +902,18 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let (warmup_caches, mode) = if let Some(mut picker) = guard.take() {
|
||||
let warmup = picker.need_warmup_mmap_cache();
|
||||
let (warmup_caches, content_indexing, watch, mode) = if let Some(mut picker) = guard.take() {
|
||||
let warmup = picker.has_mmap_cache();
|
||||
let enable_content_indexing = picker.has_content_indexing();
|
||||
let watch = picker.has_watcher();
|
||||
let mode = picker.mode();
|
||||
|
||||
picker.stop_background_monitor();
|
||||
(warmup, mode)
|
||||
|
||||
(warmup, enable_content_indexing, watch, mode)
|
||||
} else {
|
||||
(false, FFFMode::default())
|
||||
// this is error state anyway
|
||||
(false, true, true, FFFMode::default())
|
||||
};
|
||||
|
||||
drop(guard);
|
||||
@@ -664,10 +923,11 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
inst.frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: canonical_path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: warmup_caches,
|
||||
enable_mmap_cache: warmup_caches,
|
||||
enable_content_indexing: content_indexing,
|
||||
watch,
|
||||
mode,
|
||||
cache_budget: None,
|
||||
..Default::default()
|
||||
},
|
||||
) {
|
||||
Ok(()) => FffResult::ok_empty(),
|
||||
@@ -1167,3 +1427,147 @@ pub unsafe extern "C" fn fff_free_string(s: *mut c_char) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory search: free and accessor functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Free a directory search result returned by `fff_search_directories`.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid pointer previously returned via `FffResult.handle`
|
||||
/// from `fff_search_directories`, or null (no-op).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_free_dir_search_result(result: *mut FffDirSearchResult) {
|
||||
if result.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = Box::from_raw(result);
|
||||
let count = result.count as usize;
|
||||
|
||||
if !result.items.is_null() {
|
||||
let mut items = Vec::from_raw_parts(result.items, count, count);
|
||||
for item in &mut items {
|
||||
item.free_strings();
|
||||
}
|
||||
}
|
||||
if !result.scores.is_null() {
|
||||
let mut scores = Vec::from_raw_parts(result.scores, count, count);
|
||||
for score in &mut scores {
|
||||
score.free_strings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a pointer to the `index`-th `FffDirItem` in a directory search result.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffDirSearchResult` pointer from `fff_search_directories`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_dir_search_result_get_item(
|
||||
result: *const FffDirSearchResult,
|
||||
index: u32,
|
||||
) -> *const FffDirItem {
|
||||
if result.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
let result = unsafe { &*result };
|
||||
if index >= result.count || result.items.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
unsafe { result.items.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Get a pointer to the `index`-th `FffScore` in a directory search result.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffDirSearchResult` pointer from `fff_search_directories`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_dir_search_result_get_score(
|
||||
result: *const FffDirSearchResult,
|
||||
index: u32,
|
||||
) -> *const FffScore {
|
||||
if result.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
let result = unsafe { &*result };
|
||||
if index >= result.count || result.scores.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
unsafe { result.scores.add(index as usize) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed search: free and accessor functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Free a mixed search result returned by `fff_search_mixed`.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid pointer previously returned via `FffResult.handle`
|
||||
/// from `fff_search_mixed`, or null (no-op).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_free_mixed_search_result(result: *mut FffMixedSearchResult) {
|
||||
if result.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = Box::from_raw(result);
|
||||
let count = result.count as usize;
|
||||
|
||||
if !result.items.is_null() {
|
||||
let mut items = Vec::from_raw_parts(result.items, count, count);
|
||||
for item in &mut items {
|
||||
item.free_strings();
|
||||
}
|
||||
}
|
||||
if !result.scores.is_null() {
|
||||
let mut scores = Vec::from_raw_parts(result.scores, count, count);
|
||||
for score in &mut scores {
|
||||
score.free_strings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a pointer to the `index`-th `FffMixedItem` in a mixed search result.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffMixedSearchResult` pointer from `fff_search_mixed`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_mixed_search_result_get_item(
|
||||
result: *const FffMixedSearchResult,
|
||||
index: u32,
|
||||
) -> *const FffMixedItem {
|
||||
if result.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
let result = unsafe { &*result };
|
||||
if index >= result.count || result.items.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
unsafe { result.items.add(index as usize) }
|
||||
}
|
||||
|
||||
/// Get a pointer to the `index`-th `FffScore` in a mixed search result.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `result` must be a valid `FffMixedSearchResult` pointer from `fff_search_mixed`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_mixed_search_result_get_score(
|
||||
result: *const FffMixedSearchResult,
|
||||
index: u32,
|
||||
) -> *const FffScore {
|
||||
if result.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
let result = unsafe { &*result };
|
||||
if index >= result.count || result.scores.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
unsafe { result.scores.add(index as usize) }
|
||||
}
|
||||
|
||||
+21
-16
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-search"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
|
||||
@@ -10,6 +10,18 @@ description = "Faboulous & Fast File Finder - a fast and extremely correct file
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
|
||||
[[bench]]
|
||||
name = "parse_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "bigram_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "memmem_bench"
|
||||
harness = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
@@ -27,17 +39,18 @@ rayon = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
fff-query-parser = { workspace = true , version = "0.5.2" }
|
||||
fff-query-parser = { workspace = true }
|
||||
|
||||
# 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 }
|
||||
fff-grep = { workspace = true , version = "0.5.2" }
|
||||
fff-grep = { workspace = true }
|
||||
aho-corasick = "1"
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
@@ -50,6 +63,7 @@ once_cell = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
regex-syntax = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
@@ -57,24 +71,15 @@ 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"] }
|
||||
libmimalloc-sys = { version = "0.1", optional = true, features = ["extended", "local_dynamic_tls"] }
|
||||
mimalloc = { version = "0.1", optional = true, features = ["local_dynamic_tls"] }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
ctor = "0.2"
|
||||
proptest = { version = "1", default-features = false, features = ["std", "fork"] }
|
||||
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
|
||||
|
||||
@@ -90,7 +90,8 @@ fn bench_bigram_build(c: &mut Criterion) {
|
||||
let file_counts = [10_000, 100_000];
|
||||
|
||||
for &file_count in &file_counts {
|
||||
// Pre-generate content so we only measure index building
|
||||
// Pre-generate content so we only measure index building.
|
||||
// Short content (~85 bytes/file) exercises the scalar fast path.
|
||||
let contents: Vec<String> = (0..file_count)
|
||||
.map(|i| {
|
||||
format!(
|
||||
@@ -100,7 +101,7 @@ fn bench_bigram_build(c: &mut Criterion) {
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("build_and_compress", file_count),
|
||||
BenchmarkId::new("short_content", file_count),
|
||||
&file_count,
|
||||
|b, &fc| {
|
||||
b.iter(|| {
|
||||
@@ -114,6 +115,40 @@ fn bench_bigram_build(c: &mut Criterion) {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Long content (~4 KB/file) exercises the SIMD pre-pass path.
|
||||
// Build a realistic-looking source-like blob by repeating snippets.
|
||||
let long_contents: Vec<String> = (0..file_count)
|
||||
.map(|i| {
|
||||
let mut s = String::with_capacity(4096);
|
||||
for j in 0..50 {
|
||||
s.push_str(&format!(
|
||||
"pub fn handler_{i}_{j}(ctx: &Context) -> Result<Response, Error> {{\n"
|
||||
));
|
||||
s.push_str(" let parsed = ctx.parse()?;\n");
|
||||
s.push_str(" let validated = parsed.validate()?;\n");
|
||||
s.push_str(&format!(" ctx.respond(validated, {}).await\n", j));
|
||||
s.push_str("}\n\n");
|
||||
}
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("long_content", file_count),
|
||||
&file_count,
|
||||
|b, &fc| {
|
||||
b.iter(|| {
|
||||
let builder = BigramIndexBuilder::new(fc);
|
||||
let skip_builder = BigramIndexBuilder::new(fc);
|
||||
for (i, content) in long_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();
|
||||
|
||||
+19
-19
@@ -1,36 +1,36 @@
|
||||
fn main() {
|
||||
// Opt-in cfg for the long-running randomized stress tests
|
||||
// used by tests/fuzz_git_watcher_stress.rs
|
||||
println!("cargo::rustc-check-cfg=cfg(stress)");
|
||||
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
|
||||
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"The `zlob` feature is enabled but Zig is not installed. \
|
||||
Install Zig (https://ziglang.org/download/) or build without \
|
||||
`--features zlob`."
|
||||
);
|
||||
}
|
||||
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
}
|
||||
} else if std::env::var("CI").is_ok() {
|
||||
// CI must always build with zlob for production-quality binaries.
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"CI detected but Zig is not installed. \
|
||||
Please install Zig and build with `--features zlob`."
|
||||
);
|
||||
}
|
||||
panic!(
|
||||
"CI detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob`."
|
||||
);
|
||||
} else {
|
||||
} else if std::env::var("CARGO_PRIMARY_PACKAGE").is_ok() && zig_available() {
|
||||
// Hint: if Zig is available but the zlob feature wasn't enabled,
|
||||
// let the developer know they can get faster glob matching.
|
||||
if zig_available() {
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
// Only emit this hint when this crate is the primary package to
|
||||
// avoid noisy warnings for downstream consumers.
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::{FFFMode, FilePicker};
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::shared::{SharedFrecency, SharedPicker};
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify::{Config, EventKind, EventKindMask, RecursiveMode};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
@@ -17,35 +18,28 @@ type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, No
|
||||
|
||||
/// 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 {
|
||||
stop_signal: Arc<AtomicBool>,
|
||||
debouncer: Arc<Mutex<Option<Debouncer>>>,
|
||||
watch_tx: Option<mpsc::Sender<PathBuf>>,
|
||||
owner_thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50);
|
||||
const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
|
||||
/// On macOS, each `watch()` call creates a separate FSEventStream. When the
|
||||
/// number of directories exceeds this threshold we fall back to a single
|
||||
/// recursive watch to avoid exhausting the per-process stream limit.
|
||||
const MAX_MACOS_NONRECURSIVE_WATCHES: usize = 4096;
|
||||
/// 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;
|
||||
const MAX_OVERFLOW_FILES: usize = 1024;
|
||||
|
||||
impl BackgroundWatcher {
|
||||
pub fn new(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_picker: SharedFilePicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) -> Result<Self, Error> {
|
||||
@@ -55,36 +49,116 @@ impl BackgroundWatcher {
|
||||
mode,
|
||||
);
|
||||
|
||||
let debouncer =
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
|
||||
// Refuse to watch the filesystem root or the user's home directory.
|
||||
// These are prone to high-volume event churn (editor temp files,
|
||||
// browser caches, log rotations) which inflates the overflow arena
|
||||
// and, on macOS, can exhaust the per-process FSEvents stream limit.
|
||||
if base_path.parent().is_none()
|
||||
|| Some(base_path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str())
|
||||
{
|
||||
return Err(Error::FilesystemRoot(base_path));
|
||||
}
|
||||
|
||||
// macOS: always use a single recursive FSEvent stream.
|
||||
//
|
||||
// Per-dir NonRecursive watches create one FSEvent stream per dir.
|
||||
// The per-process FSEvent cap is lower than expected in practice
|
||||
// (4096 per process, but FFF usually is running within code editors),
|
||||
// and each failed `watch()` after the cap blocks ~40 ms on kernel retry.
|
||||
// Yes we pay for filtering events on handler phase but it is usable
|
||||
//
|
||||
// macOS and Windows use a single recursive watch. FSEvents and
|
||||
// ReadDirectoryChangesW both support true kernel-level recursion
|
||||
// on one handle — per-dir NonRecursive watches burn streams/handles
|
||||
// for no benefit and, on Windows, have been observed to silently
|
||||
// drop Modify events for nested paths.
|
||||
//
|
||||
// Linux keeps the per-dir NonRecursive strategy: inotify has no
|
||||
// kernel-level recursion, so Recursive here would still register
|
||||
// one watch per subdir but without the ignored-dir filtering we
|
||||
// get by iterating `picker.for_each_dir` ourselves.
|
||||
let use_recursive = cfg!(any(target_os = "macos", target_os = "windows"));
|
||||
|
||||
let (watch_tx, watch_rx) = mpsc::channel::<PathBuf>();
|
||||
let watch_tx_for_debouncer = watch_tx.clone();
|
||||
|
||||
let owner_weak_picker = shared_picker.weaken();
|
||||
let owner_frecency = shared_frecency.clone();
|
||||
let owner_git_workdir = git_workdir.clone();
|
||||
|
||||
let debouncer = Self::create_debouncer(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
mode,
|
||||
use_recursive,
|
||||
watch_tx_for_debouncer,
|
||||
)?;
|
||||
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let stop_clone = Arc::clone(&stop_signal);
|
||||
// debouncer is shared with the owner thread, once it's dropped the thread is closed
|
||||
let debouncer = Arc::new(Mutex::new(Some(debouncer)));
|
||||
// Only the Linux per-dir-watch branch needs this clone; on other
|
||||
// platforms the owner thread never touches the debouncer.
|
||||
#[cfg(target_os = "linux")]
|
||||
let owner_debouncer = Arc::clone(&debouncer);
|
||||
|
||||
// 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())
|
||||
.name("fff-watcher-own".into())
|
||||
.spawn(move || {
|
||||
while !stop_clone.load(Ordering::Acquire) {
|
||||
std::thread::park_timeout(Duration::from_secs(1));
|
||||
while let Ok(dir) = watch_rx.recv() {
|
||||
// if the picker is dropped we do need to exit the loop
|
||||
let Some(strong_picker) = owner_weak_picker.upgrade() else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Only inotify (Linux) has no kernel-level recursion, so
|
||||
// it's the only platform that needs a per-subdir watch to
|
||||
// be registered at runtime. macOS FSEvents and Windows
|
||||
// ReadDirectoryChangesW are already watching recursively
|
||||
// from the base path (see `create_debouncer`), and
|
||||
// registering a second overlapping stream there produces
|
||||
// duplicate/out-of-order events.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Register the new directory with the debouncer, then
|
||||
// drop the mutex BEFORE doing picker-side work — see
|
||||
// the comment on `BackgroundWatcher::stop` for the
|
||||
// lock-ordering rationale.
|
||||
let mut guard = owner_debouncer.lock();
|
||||
let Some(debouncer) = guard.as_mut() else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Err(e) = debouncer.watch(&dir, RecursiveMode::NonRecursive) {
|
||||
warn!(
|
||||
?e,
|
||||
dir = %dir.display(),
|
||||
"Failed to init watcher for new directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
track_files_from_new_directories(
|
||||
&dir,
|
||||
&strong_picker,
|
||||
&owner_frecency,
|
||||
&owner_git_workdir,
|
||||
);
|
||||
|
||||
// Transient strong ref drops here, back
|
||||
// to weak-only before the next `recv()`.
|
||||
}
|
||||
// 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));
|
||||
|
||||
tracing::info!("Background watcher is stopped");
|
||||
})
|
||||
.expect("failed to spawn fff-watcher-owner thread");
|
||||
|
||||
Ok(Self {
|
||||
stop_signal,
|
||||
debouncer,
|
||||
watch_tx: Some(watch_tx),
|
||||
owner_thread: Some(owner_thread),
|
||||
})
|
||||
}
|
||||
@@ -92,29 +166,61 @@ impl BackgroundWatcher {
|
||||
fn create_debouncer(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_picker: SharedFilePicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
use_recursive: bool,
|
||||
watch_tx: mpsc::Sender<PathBuf>,
|
||||
) -> 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
|
||||
// the file was edited through a
|
||||
let config = Config::default().with_follow_symlinks(false);
|
||||
let config = Config::default()
|
||||
// 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
|
||||
// the file was edited through a
|
||||
.with_follow_symlinks(false)
|
||||
// only the actual modification events, ignore the open syscals that we can generate by
|
||||
// our own grep calls and preview window rendering
|
||||
.with_event_kinds(EventKindMask::CORE);
|
||||
|
||||
// `use_recursive` was decided by the caller from a cheap size hint,
|
||||
// so the event-handler closure can capture it directly.
|
||||
//
|
||||
// The closure lives on the debouncer's internal event thread
|
||||
// for as long as the debouncer exists — i.e. the full
|
||||
// lifetime of `BackgroundWatcher`. Capturing a strong
|
||||
// `SharedFilePicker` here would re-introduce the Arc cycle
|
||||
// we just broke with `owner_picker`'s `downgrade()` above.
|
||||
// Capture a weak handle instead and upgrade per-batch.
|
||||
let git_workdir_for_handler = git_workdir.clone();
|
||||
let shared_picker_for_watching = shared_picker.clone();
|
||||
let event_picker = shared_picker.weaken();
|
||||
let mut debouncer = new_debouncer_opt(
|
||||
DEBOUNCE_TIMEOUT,
|
||||
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
|
||||
{
|
||||
move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
handle_debounced_events(
|
||||
// Upgrade just long enough to drive one
|
||||
// debounced batch. Failure means every
|
||||
// external `SharedFilePicker` has already
|
||||
// dropped and teardown is already underway.
|
||||
let Some(strong_picker) = event_picker.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let new_dirs = handle_debounced_events(
|
||||
events,
|
||||
&git_workdir_for_handler,
|
||||
&shared_picker,
|
||||
&strong_picker,
|
||||
&shared_frecency,
|
||||
mode,
|
||||
);
|
||||
|
||||
// every new directory creates had to be reflected in the picker state
|
||||
for dir in new_dirs {
|
||||
if let Err(e) = watch_tx.send(dir) {
|
||||
warn!(?e, "Failed to send directory update error");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(errors) => {
|
||||
error!("File watcher errors: {:?}", errors);
|
||||
@@ -129,59 +235,155 @@ impl BackgroundWatcher {
|
||||
config,
|
||||
)?;
|
||||
|
||||
// Watch only non-ignored directories to avoid flooding the OS event buffer.
|
||||
// On macOS, FSEvents has a fixed-size kernel buffer — watching huge gitignored
|
||||
// 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, git_workdir.is_some());
|
||||
// Watching strategy:
|
||||
//
|
||||
// For small-to-medium repos we watch each indexed directory individually
|
||||
// (NonRecursive). This avoids receiving events for gitignored paths like
|
||||
// node_modules/ and keeps the event volume low.
|
||||
//
|
||||
// On macOS, each `watch()` call creates a separate FSEventStream. Large
|
||||
// repos (e.g. Chromium with 487K+ files) can have tens of thousands of
|
||||
// directories, which exhausts the per-process FSEvents stream limit and
|
||||
// causes "unable to start FSEvent stream" errors. When the directory
|
||||
// count exceeds the threshold we fall back to a single Recursive watch
|
||||
// on the base path. FSEvents handles this efficiently with one kernel
|
||||
// stream for the entire subtree. Gitignored paths are already filtered
|
||||
// in the event handler via `should_include_file()`.
|
||||
//
|
||||
// On Linux (inotify), RecursiveMode::Recursive creates one kernel watch
|
||||
// per subdirectory *including* gitignored ones, wasting file descriptors.
|
||||
// The per-directory NonRecursive approach is always used on Linux.
|
||||
//
|
||||
// New directories created at runtime are detected via Create events on
|
||||
// the parent and dynamically added by the owner thread via watch_tx.
|
||||
|
||||
if watch_dirs.len() > MAX_SELECTIVE_WATCH_DIRS {
|
||||
tracing::warn!(
|
||||
"Too many non-ignored directories ({}/{}) can't efficiently watch them",
|
||||
watch_dirs.len(),
|
||||
MAX_SELECTIVE_WATCH_DIRS
|
||||
);
|
||||
if use_recursive {
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
info!(
|
||||
"File watcher initialized with single recursive watch on {} \
|
||||
(exceeded threshold of {})",
|
||||
base_path.display(),
|
||||
MAX_MACOS_NONRECURSIVE_WATCHES,
|
||||
);
|
||||
} else {
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
|
||||
|
||||
for dir in &watch_dirs {
|
||||
match debouncer.watch(dir.as_path(), RecursiveMode::Recursive) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// Non-fatal: directory may have been removed between discovery and watch
|
||||
warn!("Failed to watch directory {}: {}", dir.display(), e);
|
||||
// Stream watch-dir registration directly under the picker
|
||||
// read lock. Only Linux (inotify) reaches this branch —
|
||||
// macOS always takes the recursive path above. `inotify`'s
|
||||
// `inotify_add_watch()` is fast-fail: on ENOSPC it returns
|
||||
// immediately, no kernel retry loop, so holding the read
|
||||
// lock across the stream is O(ms) even for large repos.
|
||||
//
|
||||
// Abort the loop after a run of failures. Once ENOSPC hits,
|
||||
// further calls won't succeed until the user raises
|
||||
// `fs.inotify.max_user_watches`, so there's no value in
|
||||
// continuing.
|
||||
const MAX_CONSECUTIVE_WATCH_FAILURES: usize = 16;
|
||||
|
||||
let mut watched = 0usize;
|
||||
let mut consecutive_failures = 0usize;
|
||||
let mut aborted_early = false;
|
||||
|
||||
if let Some(guard) = shared_picker_for_watching.read().ok()
|
||||
&& let Some(picker) = guard.as_ref()
|
||||
{
|
||||
use std::ops::ControlFlow;
|
||||
picker.for_each_dir(|dir| {
|
||||
match debouncer.watch(dir, RecursiveMode::NonRecursive) {
|
||||
Ok(()) => {
|
||||
watched += 1;
|
||||
consecutive_failures = 0;
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures <= 4 {
|
||||
warn!("Failed to watch directory {}: {}", dir.display(), e);
|
||||
}
|
||||
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_WATCH_FAILURES {
|
||||
warn!(
|
||||
consecutive_failures,
|
||||
watched,
|
||||
"Aborting NonRecursive watch loop — per-process \
|
||||
watch cap exhausted, further dirs would just burn \
|
||||
kernel time for no coverage"
|
||||
);
|
||||
aborted_early = true;
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// In selective mode the .git directory is excluded from the non-ignored
|
||||
// dirs, but we still need to observe changes that affect git status
|
||||
// (staging, unstaging, committing, branch switches, merges, etc.).
|
||||
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
|
||||
info!(
|
||||
"File watcher initialized for {} directories (NonRecursive) under {} (aborted_early={})",
|
||||
watched,
|
||||
base_path.display(),
|
||||
aborted_early,
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
"File watcher initialized for {} directories under {}",
|
||||
watch_dirs.len(),
|
||||
base_path.display()
|
||||
);
|
||||
// The .git directory is excluded from the file list but we still need
|
||||
// to observe changes that affect git status (staging, unstaging,
|
||||
// committing, branch switches, merges, etc.).
|
||||
// When using recursive mode the base watch already covers .git/,
|
||||
// but these targeted watches are cheap (at most 3 extra streams)
|
||||
// and ensure we catch status changes even if the recursive backend
|
||||
// coalesces or delays .git events.
|
||||
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
|
||||
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
/// Signal the watcher to shut down without blocking on its worker
|
||||
/// threads. Safe to call from any context, including while holding
|
||||
/// the [`SharedFilePicker`] write lock.
|
||||
///
|
||||
/// Both the debouncer's internal event loop and our owner thread
|
||||
/// may call `SharedFilePicker::write()` inside their handlers. A
|
||||
/// blocking join here would deadlock against a caller that already
|
||||
/// holds that lock (e.g. `stop_background_monitor` under a
|
||||
/// `shared_picker.write()` guard). Instead we:
|
||||
///
|
||||
/// * drop the `watch_tx` Sender — the owner thread's
|
||||
/// `watch_rx.recv()` returns `Err` and the thread exits at
|
||||
/// its next `recv`.
|
||||
/// * call `debouncer.stop_nonblocking()` — signals the debouncer
|
||||
/// event loop to exit on its next tick and drops the watcher,
|
||||
/// closing the FSEvent / inotify / ReadDirectoryChangesW stream.
|
||||
/// * detach both `JoinHandle`s.
|
||||
///
|
||||
/// In-flight handler invocations finish on their own (at most one
|
||||
/// more batch) once the caller releases any locks they hold.
|
||||
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);
|
||||
}
|
||||
self.watch_tx.take();
|
||||
if let Some(debouncer) = self.debouncer.lock().take() {
|
||||
debouncer.stop_nonblocking();
|
||||
}
|
||||
|
||||
info!("Background file watcher stopped successfully");
|
||||
self.owner_thread.take();
|
||||
|
||||
info!("Background file watcher stop signaled");
|
||||
}
|
||||
|
||||
/// Queue a non-recursive watch registration on `dir`.
|
||||
///
|
||||
/// The owner thread is always blocked on `watch_rx.recv()`, so
|
||||
/// the `send()` here wakes it immediately via the channel's
|
||||
/// condvar — no external unpark needed.
|
||||
///
|
||||
/// Returns `false` once `stop()` has dropped our `Sender` — any
|
||||
/// further request is silently discarded.
|
||||
pub(crate) fn request_watch_dir(&self, dir: PathBuf) -> bool {
|
||||
match self.watch_tx.as_ref() {
|
||||
Some(tx) => tx.send(dir).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,16 +397,18 @@ impl Drop for BackgroundWatcher {
|
||||
fn handle_debounced_events(
|
||||
events: Vec<DebouncedEvent>,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_picker: &SharedFilePicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
mode: FFFMode,
|
||||
) {
|
||||
) -> Vec<PathBuf> {
|
||||
// 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());
|
||||
let mut need_full_rescan = false;
|
||||
let mut need_full_git_rescan = false;
|
||||
let mut paths_to_remove = Vec::new();
|
||||
let mut dirs_to_remove: Vec<PathBuf> = Vec::new();
|
||||
let mut paths_to_add_or_modify = Vec::new();
|
||||
let mut new_dirs_to_watch = Vec::new();
|
||||
let mut affected_paths_count = 0usize;
|
||||
|
||||
for debounced_event in &events {
|
||||
@@ -264,9 +468,28 @@ fn handle_debounced_events(
|
||||
// - Remove events are not always emitted (macOS often sends
|
||||
// Modify(Name(Any)) instead of Remove).
|
||||
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
|
||||
// Directory-level remove: macOS FSEvents delivers a single
|
||||
// `Remove(Folder)` event for a whole directory tree (e.g.
|
||||
// after `git reset --hard` wipes a dir full of staged-but-
|
||||
// uncommitted files). Individual per-file Remove events for
|
||||
// the children do *not* arrive. Treat the folder removal as
|
||||
// "evict every indexed descendant".
|
||||
let is_folder_removal = matches!(
|
||||
debounced_event.event.kind,
|
||||
EventKind::Remove(notify::event::RemoveKind::Folder)
|
||||
);
|
||||
|
||||
if is_removal || !path.exists() {
|
||||
if is_folder_removal {
|
||||
dirs_to_remove.push(path.to_path_buf());
|
||||
} else if is_removal || !path.exists() {
|
||||
paths_to_remove.push(path.as_path());
|
||||
} else if path.is_dir() {
|
||||
// New directory — collect it so the caller can register a
|
||||
// watcher. No filesystem scanning: files that arrive later
|
||||
// will be handled by the newly registered watch.
|
||||
if !is_path_ignored(path, &repo) {
|
||||
new_dirs_to_watch.push(path.to_path_buf());
|
||||
}
|
||||
} else {
|
||||
// For additions/modifications, still filter gitignored files.
|
||||
if should_include_file(path, &repo) {
|
||||
@@ -293,8 +516,10 @@ fn handle_debounced_events(
|
||||
|
||||
if need_full_rescan {
|
||||
info!(?affected_paths_count, "Triggering full rescan");
|
||||
trigger_full_rescan(shared_picker, shared_frecency);
|
||||
return;
|
||||
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// It's important to get the allocated sort
|
||||
@@ -304,65 +529,90 @@ fn handle_debounced_events(
|
||||
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
|
||||
|
||||
info!(
|
||||
"Event processing summary: {} to remove, {} to add/modify",
|
||||
"Event processing summary: {} to remove, {} dirs to remove, {} to add/modify, {} new dirs",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len()
|
||||
dirs_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
new_dirs_to_watch.len()
|
||||
);
|
||||
|
||||
// Apply file index updates (add/remove) unconditionally — these must
|
||||
// happen even when there is no git repository.
|
||||
let files_to_update_git_status =
|
||||
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
|
||||
debug!(
|
||||
"Applying file index changes: {} to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
let (files_to_update_git_status, overflow_count) = if !paths_to_remove.is_empty()
|
||||
|| !dirs_to_remove.is_empty()
|
||||
|| !paths_to_add_or_modify.is_empty()
|
||||
{
|
||||
debug!(
|
||||
"Applying file index changes: {} to remove, {} dirs to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
dirs_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
);
|
||||
|
||||
let apply_changes = |picker: &mut FilePicker| -> (Vec<PathBuf>, usize) {
|
||||
// Remove whole directories first so any subsequent single-file
|
||||
// remove event for a path that lived under them becomes a cheap
|
||||
// no-op rather than a failed lookup.
|
||||
for dir in &dirs_to_remove {
|
||||
let count = picker.remove_all_files_in_dir(dir);
|
||||
debug!("remove_all_files_in_dir({:?}) -> {} files", dir, count);
|
||||
}
|
||||
|
||||
for path in &paths_to_remove {
|
||||
let removed = picker.remove_file_by_path(path);
|
||||
debug!("remove_file_by_path({:?}) -> {}", path, removed);
|
||||
}
|
||||
|
||||
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in &paths_to_add_or_modify {
|
||||
let added = picker.on_create_or_modify(path).is_some();
|
||||
if added {
|
||||
debug!("on_create_or_modify({:?}) -> Some", path);
|
||||
files_to_update.push(path.to_path_buf());
|
||||
} else {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
}
|
||||
}
|
||||
let overflow_count = picker.get_overflow_files().len();
|
||||
info!(
|
||||
"apply_changes complete: {} files to update git status, overflow={}",
|
||||
files_to_update.len(),
|
||||
overflow_count,
|
||||
);
|
||||
|
||||
let apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
|
||||
for path in &paths_to_remove {
|
||||
let removed = picker.remove_file_by_path(path);
|
||||
debug!("remove_file_by_path({:?}) -> {}", path, removed);
|
||||
}
|
||||
|
||||
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in &paths_to_add_or_modify {
|
||||
let result = picker.on_create_or_modify(path);
|
||||
match result {
|
||||
Some(file) => {
|
||||
debug!(
|
||||
"on_create_or_modify({:?}) -> Some({})",
|
||||
path,
|
||||
file.path_str()
|
||||
);
|
||||
files_to_update.push(PathBuf::from(file.path_str()));
|
||||
}
|
||||
None => {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"apply_changes complete: {} files to update git status",
|
||||
files_to_update.len()
|
||||
);
|
||||
files_to_update
|
||||
};
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock");
|
||||
return;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized");
|
||||
return;
|
||||
};
|
||||
apply_changes(picker)
|
||||
} else {
|
||||
debug!("No file index changes to apply");
|
||||
Vec::new()
|
||||
(files_to_update, overflow_count)
|
||||
};
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock");
|
||||
return new_dirs_to_watch;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized");
|
||||
return new_dirs_to_watch;
|
||||
};
|
||||
apply_changes(picker)
|
||||
} else {
|
||||
debug!("No file index changes to apply");
|
||||
(Vec::new(), 0)
|
||||
};
|
||||
|
||||
// The overflow arena grows monotonically as new files are created — a
|
||||
// file's chunks are added on creation but never reclaimed on removal.
|
||||
// On directories with high churn (e.g. `$HOME` with editor temp files,
|
||||
// browser caches) this inflates RSS unboundedly. Once overflow exceeds
|
||||
// the threshold, fall back to a full rescan: that replaces `sync_data`
|
||||
// and drops the builder arena, which is the only path that reclaims it.
|
||||
if overflow_count > MAX_OVERFLOW_FILES {
|
||||
warn!(
|
||||
?overflow_count,
|
||||
"Overflow count exceeded the threshold, triggering full rescan.",
|
||||
);
|
||||
if let Err(e) = shared_picker.trigger_full_rescan_async(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
}
|
||||
return new_dirs_to_watch;
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -410,17 +660,25 @@ fn handle_debounced_events(
|
||||
// Git status updates require a repository.
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
debug!("No git repo available, skipping git status updates");
|
||||
return;
|
||||
return new_dirs_to_watch;
|
||||
};
|
||||
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
|
||||
let result = shared_picker.refresh_git_status(shared_frecency);
|
||||
if let Err(e) = result {
|
||||
if let Err(e) = shared_picker.refresh_git_status(shared_frecency) {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
return;
|
||||
// IMPORTANT: do NOT return here. When a batch contains both
|
||||
// `.git/index` events (e.g. from `git add`) AND worktree-file
|
||||
// Modify events (e.g. a subsequent edit to the same file),
|
||||
// `refresh_git_status` might run while libgit2 sees an
|
||||
// intermediate state — lock-wait mitigates this but can't fully
|
||||
// eliminate it, and refresh doesn't always observe the final
|
||||
// worktree contents if the edit event landed just before the
|
||||
// batch flushed. Re-running the per-path query for explicitly
|
||||
// changed files overrides any stale bits from refresh with an
|
||||
// authoritative per-file status read.
|
||||
}
|
||||
|
||||
if !files_to_update_git_status.is_empty() {
|
||||
@@ -433,7 +691,7 @@ fn handle_debounced_events(
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to query git status");
|
||||
return;
|
||||
return new_dirs_to_watch;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -449,28 +707,75 @@ fn handle_debounced_events(
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
}
|
||||
}
|
||||
|
||||
new_dirs_to_watch
|
||||
}
|
||||
|
||||
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
|
||||
info!("Triggering full filesystem rescan");
|
||||
|
||||
// Note: no need to clear mmaps — they are backed by the kernel page cache
|
||||
// and automatically reflect file changes. Old FileItems (and their mmaps)
|
||||
// are dropped when the picker rebuilds its file list.
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock for full rescan");
|
||||
/// After registering a watch on a newly created directory, list its
|
||||
/// immediate children and add any files to the picker.
|
||||
fn track_files_from_new_directories(
|
||||
dir: &Path,
|
||||
shared_picker: &SharedFilePicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized, cannot trigger rescan");
|
||||
return;
|
||||
};
|
||||
if let Err(e) = picker.trigger_rescan(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
} else {
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
let mut files_to_add = Vec::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|ft| ft.is_file()) {
|
||||
let path = entry.path();
|
||||
if should_include_file(&path, &repo) {
|
||||
files_to_add.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if files_to_add.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// brief read lock
|
||||
{
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(ref mut picker) = *guard else {
|
||||
return;
|
||||
};
|
||||
|
||||
for path in &files_to_add {
|
||||
picker.on_create_or_modify(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(repo) = repo.as_ref() {
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_add) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "inject_existing_files: git status query failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
&& let Err(e) = picker.update_git_statuses(status, shared_frecency)
|
||||
{
|
||||
error!("inject_existing_files: failed to update git statuses: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Injected {} existing files from new directory {}",
|
||||
files_to_add.len(),
|
||||
dir.display(),
|
||||
);
|
||||
}
|
||||
|
||||
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
@@ -495,36 +800,46 @@ fn is_non_code_directory(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_git_file(path: &Path) -> bool {
|
||||
fn is_path_ignored(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) == Ok(true),
|
||||
None => is_non_code_directory(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_git_file(path: &Path) -> bool {
|
||||
path.components()
|
||||
.any(|component| component.as_os_str() == ".git")
|
||||
}
|
||||
|
||||
pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
|
||||
fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let git_dir = repo.path();
|
||||
|
||||
if let Ok(rel) = changed.strip_prefix(git_dir) {
|
||||
if rel.starts_with("objects") || rel.starts_with("logs") || rel.starts_with("hooks") {
|
||||
return false;
|
||||
}
|
||||
if rel == Path::new("index") || rel == Path::new("index.lock") {
|
||||
if let Ok(path_in_git_dir) = changed.strip_prefix(git_dir) {
|
||||
// Only react to changes that rewrite the worktree state: commits,
|
||||
// staging, checkouts, merges, conflict resolution. Ref-only updates
|
||||
// under refs/ (fetch, push, tag writes, pack-refs) do not change
|
||||
// which files are modified/untracked, so we deliberately skip them —
|
||||
// watching refs/ recursively would cost one inotify watch per ref
|
||||
// namespace on repos with many branches/remotes.
|
||||
if path_in_git_dir == Path::new("index") || path_in_git_dir == Path::new("index.lock") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("HEAD") {
|
||||
if path_in_git_dir == Path::new("HEAD") {
|
||||
return true;
|
||||
}
|
||||
if rel.starts_with("refs") || rel == Path::new("packed-refs") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("info/exclude") || rel == Path::new("info/sparse-checkout") {
|
||||
if path_in_git_dir == Path::new("info/exclude")
|
||||
|| path_in_git_dir == Path::new("info/sparse-checkout")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(fname) = rel.file_name().and_then(|f| f.to_str())
|
||||
if let Some(fname) = path_in_git_dir.file_name().and_then(|f| f.to_str())
|
||||
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
|
||||
{
|
||||
return true;
|
||||
@@ -552,20 +867,15 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
|
||||
}
|
||||
|
||||
// Watch .git/ non-recursively to catch top-level files:
|
||||
// index, index.lock, HEAD, packed-refs, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD
|
||||
// index, index.lock, HEAD, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD.
|
||||
// We intentionally do NOT watch refs/ — individual ref updates don't
|
||||
// affect worktree status, and a recursive watch there blows up inotify
|
||||
// watch counts on repos with many branches/remotes/tags.
|
||||
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
|
||||
warn!("Failed to watch .git directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch refs/ recursively to catch branch/tag changes
|
||||
let refs_dir = git_dir.join("refs");
|
||||
if refs_dir.is_dir()
|
||||
&& let Err(e) = debouncer.watch(&refs_dir, RecursiveMode::Recursive)
|
||||
{
|
||||
warn!("Failed to watch .git/refs: {}", e);
|
||||
}
|
||||
|
||||
// Watch info/ non-recursively for exclude and sparse-checkout
|
||||
let info_dir = git_dir.join("info");
|
||||
if info_dir.is_dir()
|
||||
@@ -574,45 +884,3 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
|
||||
warn!("Failed to watch .git/info: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
|
||||
/// 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, has_git_repo: bool) -> Vec<PathBuf> {
|
||||
use crate::ignore::non_git_repo_overrides;
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
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));
|
||||
|
||||
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 {
|
||||
let Ok(entry) = entry else { continue };
|
||||
let path = entry.path();
|
||||
|
||||
// Skip the root directory itself
|
||||
if path == base_path {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() && !is_git_file(path) {
|
||||
dirs.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use ahash::AHashMap;
|
||||
use rayon::iter::{IndexedParallelIterator, ParallelIterator};
|
||||
use rayon::slice::ParallelSlice;
|
||||
use std::cell::UnsafeCell;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU16, AtomicUsize, Ordering};
|
||||
|
||||
/// Maximum number of distinct bigrams tracked in the inverted index.
|
||||
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
|
||||
@@ -17,24 +20,27 @@ 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>,
|
||||
/// Flat bitset data, materialised on first use.
|
||||
col_data: OnceLock<UnsafeCell<Box<[u64]>>>,
|
||||
next_column: AtomicU16,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: AtomicUsize,
|
||||
}
|
||||
|
||||
// SAFETY: `col_data`'s interior mutability is coordinated via disjoint
|
||||
// `word_idx` ranges (word-aligned file partitioning in the driver), so
|
||||
// concurrent access is safe despite the `UnsafeCell`. See builder doc.
|
||||
unsafe impl Sync for BigramIndexBuilder {}
|
||||
|
||||
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,
|
||||
col_data: OnceLock::new(),
|
||||
next_column: AtomicU16::new(0),
|
||||
words,
|
||||
file_count,
|
||||
@@ -42,6 +48,23 @@ impl BigramIndexBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily materialise the full `MAX_BIGRAM_COLUMNS * words` bitset
|
||||
/// on first access.
|
||||
#[inline(always)]
|
||||
fn col_data_cell(&self) -> &UnsafeCell<Box<[u64]>> {
|
||||
self.col_data.get_or_init(|| {
|
||||
let total = MAX_BIGRAM_COLUMNS * self.words;
|
||||
UnsafeCell::new(vec![0u64; total].into_boxed_slice())
|
||||
})
|
||||
}
|
||||
|
||||
/// Raw pointer to the start of the bitset slab. Used for in-place
|
||||
/// `|=` writes under the partitioning invariant.
|
||||
#[inline(always)]
|
||||
fn col_data_ptr(&self) -> *mut u64 {
|
||||
unsafe { (*self.col_data_cell().get()).as_mut_ptr() }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_or_alloc_column(&self, key: u16) -> u16 {
|
||||
let current = self.lookup[key as usize].load(Ordering::Relaxed);
|
||||
@@ -64,13 +87,36 @@ impl BigramIndexBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn column_bitset(&self, col: u16) -> &[AtomicU64] {
|
||||
let start = col as usize * self.words;
|
||||
&self.col_data[start..start + self.words]
|
||||
/// SAFETY: caller must not access the same `word_idx` slot from
|
||||
/// another thread concurrently. Partitioning in
|
||||
/// `file_picker::build_bigram_index` enforces this.
|
||||
#[inline(always)]
|
||||
unsafe fn column_word_ptr(&self, col: u16, word_idx: usize) -> *mut u64 {
|
||||
unsafe {
|
||||
self.col_data_ptr()
|
||||
.add(col as usize * self.words + word_idx)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
|
||||
/// Test/bench accessor for a column's raw bitset words. Assumes the
|
||||
/// caller has joined all writers (no concurrent mutation).
|
||||
#[cfg(test)]
|
||||
fn column_bitset(&self, col: u16) -> &[u64] {
|
||||
let start = col as usize * self.words;
|
||||
let slab = unsafe { &*self.col_data_cell().get() };
|
||||
&slab[start..start + self.words]
|
||||
}
|
||||
|
||||
// `pub` (via `#[doc(hidden)]`) only so the criterion bench can drive
|
||||
// `add_file_content` directly. External consumers should use
|
||||
// `build_bigram_index` instead.
|
||||
///
|
||||
/// SAFETY: concurrent callers must partition `file_idx` by
|
||||
/// word-aligned ranges so that `file_idx / 64` never collides across
|
||||
/// threads. The `file_picker::build_bigram_index` driver enforces
|
||||
/// this via `par_chunks` with a word-aligned chunk size.
|
||||
#[doc(hidden)]
|
||||
pub fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
|
||||
if content.len() < 2 {
|
||||
return;
|
||||
}
|
||||
@@ -79,68 +125,75 @@ impl BigramIndexBuilder {
|
||||
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
|
||||
// Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536
|
||||
// bigram keys with margin. Has to fit in L1 cache.
|
||||
let mut seen_consec = [0u64; 1024];
|
||||
let mut seen_skip = [0u64; 1024];
|
||||
|
||||
// Normalise each byte as we stream and carry a 2-byte history
|
||||
// across iterations so each input byte is normalised exactly once
|
||||
// even though it participates in up to three bigrams (as `cur`,
|
||||
// then `prev`, then `skip_prev`). Benchmarked against a NEON
|
||||
// pre-pass variant — the pre-pass needs a heap scratch per call,
|
||||
// which kills throughput unless content is gigantic. Inline
|
||||
// normalisation is the faster choice for realistic file sizes.
|
||||
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);
|
||||
}
|
||||
let mut n0 = normalize_byte_scalar(bytes[0]);
|
||||
let mut n1 = normalize_byte_scalar(bytes[1]);
|
||||
|
||||
if n0 != u16::MAX && n1 != u16::MAX {
|
||||
let key = (n0 << 8) | n1;
|
||||
self.record_bigram(&mut seen_consec, key, word_idx, bit_mask);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
for &b in &bytes[2..len] {
|
||||
let cur = normalize_byte_scalar(b);
|
||||
if cur != u16::MAX {
|
||||
if n1 != u16::MAX {
|
||||
let key = (n1 << 8) | cur;
|
||||
self.record_bigram(&mut seen_consec, key, word_idx, bit_mask);
|
||||
}
|
||||
if n0 != u16::MAX {
|
||||
let key = (n0 << 8) | cur;
|
||||
skip_builder.record_bigram(&mut seen_skip, key, word_idx, bit_mask);
|
||||
}
|
||||
}
|
||||
n0 = n1;
|
||||
n1 = cur;
|
||||
}
|
||||
|
||||
self.populated.fetch_add(1, Ordering::Relaxed);
|
||||
skip_builder.populated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Mark `key` as present for the file whose column-word is `word_idx`
|
||||
/// and bit position is `bit_mask`, de-duplicating via the caller-owned
|
||||
/// `seen` bitmap so we only touch the shared column slab at most once
|
||||
/// per unique bigram per file.
|
||||
///
|
||||
/// SAFETY: under the partitioning invariant on `add_file_content`
|
||||
/// the `word_idx` slot this touches is owned exclusively by the
|
||||
/// current thread, so a plain `|=` through the raw pointer is
|
||||
/// race-free (no atomic RMW needed).
|
||||
#[inline(always)]
|
||||
fn record_bigram(&self, seen: &mut [u64; 1024], key: u16, word_idx: usize, bit_mask: u64) {
|
||||
let k = key as usize;
|
||||
let w = k >> 6;
|
||||
let bit = 1u64 << (k & 63);
|
||||
if seen[w] & bit == 0 {
|
||||
seen[w] |= bit;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
unsafe {
|
||||
let p = self.column_word_ptr(col, word_idx);
|
||||
*p |= bit_mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
@@ -157,6 +210,7 @@ impl BigramIndexBuilder {
|
||||
/// 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.
|
||||
#[inline(always)]
|
||||
pub fn compress(self, min_density_pct: Option<u32>) -> BigramFilter {
|
||||
let cols = self.columns_used() as usize;
|
||||
let words = self.words;
|
||||
@@ -165,58 +219,57 @@ impl BigramIndexBuilder {
|
||||
let dense_bytes = words * 8; // cost of one dense column
|
||||
|
||||
let old_lookup = self.lookup;
|
||||
let col_data = self.col_data;
|
||||
// If no file ever populated content, col_data was never
|
||||
// materialised. Treat as empty — every column falls through.
|
||||
let col_data: Option<Box<[u64]>> = self.col_data.into_inner().map(UnsafeCell::into_inner);
|
||||
|
||||
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;
|
||||
}
|
||||
if let Some(col_data) = col_data.as_deref() {
|
||||
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];
|
||||
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();
|
||||
}
|
||||
// count set bits to decide if this column is worth keeping.
|
||||
let mut popcount = 0u32;
|
||||
for &word in bitset.iter().take(words) {
|
||||
popcount += word.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
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
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));
|
||||
dense_data.extend_from_slice(bitset);
|
||||
}
|
||||
}
|
||||
|
||||
// col_data + old_lookup dropped here — single deallocation each,
|
||||
// no fragmentation.
|
||||
|
||||
BigramFilter {
|
||||
lookup,
|
||||
dense_data,
|
||||
@@ -230,7 +283,6 @@ impl BigramIndexBuilder {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -428,6 +480,24 @@ impl BigramFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a single input byte to its normalised form used by the bigram
|
||||
/// builder: `u16::MAX` when not printable ASCII (outside `32..=126`),
|
||||
/// otherwise the lowercased byte value in `0..=126`. The `u16::MAX`
|
||||
/// sentinel can never collide with a printable-ASCII byte so the consumer
|
||||
/// can test `!= u16::MAX` without false positives.
|
||||
///
|
||||
/// Branchless and `#[inline(always)]`: LLVM lifts the ASCII-range check
|
||||
/// and the conditional-lowercase OR into a handful of instructions per
|
||||
/// call, so calling this inside a hot loop matches a hand-unrolled
|
||||
/// equivalent.
|
||||
#[inline(always)]
|
||||
fn normalize_byte_scalar(b: u8) -> u16 {
|
||||
let printable = b.wrapping_sub(32) <= 94;
|
||||
// Branchless lowercase: OR 0x20 iff byte is in 'A'..='Z'.
|
||||
let lower = b | ((b.wrapping_sub(b'A') < 26) as u8 * 0x20);
|
||||
if printable { lower as u16 } else { u16::MAX }
|
||||
}
|
||||
|
||||
pub fn extract_bigrams(content: &[u8]) -> Vec<u16> {
|
||||
if content.len() < 2 {
|
||||
return Vec::new();
|
||||
@@ -466,9 +536,6 @@ pub struct BigramOverlay {
|
||||
/// from base query results.
|
||||
tombstones: Vec<u64>,
|
||||
|
||||
/// Bigram sets for files added after the base was built (overflow files).
|
||||
added: Vec<Vec<u16>>,
|
||||
|
||||
/// Original files count this overlay was created for.
|
||||
base_file_count: usize,
|
||||
}
|
||||
@@ -479,15 +546,10 @@ impl BigramOverlay {
|
||||
Self {
|
||||
modified: AHashMap::new(),
|
||||
tombstones: vec![0u64; words],
|
||||
added: Vec::new(),
|
||||
base_file_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_file(&mut self, content: &[u8]) {
|
||||
self.added.push(extract_bigrams(content));
|
||||
}
|
||||
|
||||
pub(crate) fn modify_file(&mut self, file_idx: usize, content: &[u8]) {
|
||||
self.modified.insert(file_idx, extract_bigrams(content));
|
||||
}
|
||||
@@ -517,22 +579,9 @@ impl BigramOverlay {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// TODO implement the bigram for overlays as well
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn query_added(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
|
||||
if pattern_bigrams.is_empty() {
|
||||
return (0..self.added.len()).collect();
|
||||
}
|
||||
self.added
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, bigrams)| {
|
||||
pattern_bigrams
|
||||
.iter()
|
||||
.all(|pb| bigrams.contains(pb))
|
||||
.then_some(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.
|
||||
@@ -540,17 +589,553 @@ impl BigramOverlay {
|
||||
&self.tombstones
|
||||
}
|
||||
|
||||
/// Remove an overflow entry by index (when the file is deleted).
|
||||
pub(crate) fn remove_added(&mut self, idx: usize) {
|
||||
if idx < self.added.len() {
|
||||
self.added.remove(idx);
|
||||
/// Get all modified file indices (for conservative overlay merging when
|
||||
/// we can't extract precise bigrams, e.g. regex patterns).
|
||||
pub(crate) fn modified_indices(&self) -> Vec<usize> {
|
||||
self.modified.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub const BIGRAM_CONTENT_CAP: usize = 64 * 1024;
|
||||
const BIGRAM_CHUNK_FILES: usize = 4 * 64;
|
||||
|
||||
/// Sparse-column cutoff for the skip-1 sub-index. Rare skip columns add
|
||||
/// little filtering power but ~25-30% of index memory, so we drop
|
||||
/// anything appearing in < 12 % of populated files.
|
||||
const SKIP_INDEX_MIN_DENSITY_PCT: u32 = 12;
|
||||
|
||||
thread_local! {
|
||||
/// Per-rayon-worker reusable read buffer. 64 KB is too large to
|
||||
/// keep on the default pthread stack (macOS ships 512 KB), so the
|
||||
/// buffer lives on the heap behind a `Box<[u8; N]>`. TLS keeps the
|
||||
/// allocation alive for the thread's lifetime so we pay the cost
|
||||
/// once, not per file.
|
||||
static READ_BUF: std::cell::RefCell<Box<[u8; BIGRAM_CONTENT_CAP]>> =
|
||||
std::cell::RefCell::new(Box::new([0u8; BIGRAM_CONTENT_CAP]));
|
||||
}
|
||||
|
||||
/// Outcome of processing one file's content.
|
||||
enum FileOutcome {
|
||||
/// Content contained a NUL byte — mark the file as binary so future
|
||||
/// greps skip it without re-reading.
|
||||
Binary,
|
||||
/// Read succeeded and the content was fed to the bigram builder.
|
||||
Indexed,
|
||||
/// File was empty or failed to open; nothing to do.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, name = "Building Bigram Index", level = tracing::Level::DEBUG)]
|
||||
pub(crate) fn build_bigram_index(
|
||||
files: &[crate::types::FileItem],
|
||||
budget: &crate::types::ContentCacheBudget,
|
||||
base_path: &std::path::Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
) -> (BigramFilter, Vec<usize>) {
|
||||
let start = std::time::Instant::now();
|
||||
tracing::info!("Building bigram index for {} files...", files.len());
|
||||
|
||||
let builder = BigramIndexBuilder::new(files.len());
|
||||
let skip_builder = BigramIndexBuilder::new(files.len());
|
||||
|
||||
// this does remove a memcpy for every single file + actually reducing open time on macos
|
||||
#[cfg(unix)]
|
||||
let base_fd: libc::c_int = open_base_dir_fd(base_path);
|
||||
#[cfg(not(unix))]
|
||||
let base_fd: i32 = -1;
|
||||
|
||||
// `content_binary` is only touched from the Binary branch below, so
|
||||
// the mutex is cold in practice. A lock-free collector wasn't worth
|
||||
// the complexity.
|
||||
let content_binary: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
crate::file_picker::BACKGROUND_THREAD_POOL.install(|| {
|
||||
files
|
||||
.par_chunks(BIGRAM_CHUNK_FILES)
|
||||
.enumerate()
|
||||
.for_each(|(chunk_idx, chunk)| {
|
||||
let base_idx = chunk_idx * BIGRAM_CHUNK_FILES;
|
||||
for (offset, file) in chunk.iter().enumerate() {
|
||||
let file_idx = base_idx + offset;
|
||||
let outcome = process_file(
|
||||
file,
|
||||
file_idx,
|
||||
&builder,
|
||||
&skip_builder,
|
||||
base_fd,
|
||||
base_path,
|
||||
arena,
|
||||
budget,
|
||||
);
|
||||
if matches!(outcome, FileOutcome::Binary) {
|
||||
content_binary.lock().unwrap().push(file_idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
if base_fd >= 0 {
|
||||
// SAFETY: we opened `base_fd` at the top of this function and
|
||||
// no worker still references it once the rayon pool joined.
|
||||
unsafe { libc::close(base_fd) };
|
||||
}
|
||||
|
||||
let content_binary_vec = content_binary.into_inner().unwrap();
|
||||
|
||||
let cols = builder.columns_used();
|
||||
let mut index = builder.compress(None);
|
||||
let skip_index = skip_builder.compress(Some(SKIP_INDEX_MIN_DENSITY_PCT));
|
||||
index.set_skip_index(skip_index);
|
||||
|
||||
// Builder buffers were freed by `compress()` above (one deallocation
|
||||
// each); nudge mimalloc to return them (and any transient allocs)
|
||||
// to the OS.
|
||||
crate::file_picker::hint_allocator_collect();
|
||||
|
||||
tracing::info!(
|
||||
"Bigram index built in {:.2}s — {} dense columns for {} files",
|
||||
start.elapsed().as_secs_f64(),
|
||||
cols,
|
||||
files.len(),
|
||||
);
|
||||
if !content_binary_vec.is_empty() {
|
||||
tracing::info!(
|
||||
"Bigram build detected {} content-binary files (not caught by extension)",
|
||||
content_binary_vec.len(),
|
||||
);
|
||||
}
|
||||
|
||||
(index, content_binary_vec)
|
||||
}
|
||||
|
||||
/// Process one file: read up to `BIGRAM_CONTENT_CAP` bytes, feed them
|
||||
/// to the bigram builder (or record as binary / skipped).
|
||||
///
|
||||
/// `base_fd` is the parent-directory fd for the Unix `openat` fast
|
||||
/// path, or `-1` to force the portable `std::fs::File::open` fallback.
|
||||
#[inline]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_file(
|
||||
file: &crate::types::FileItem,
|
||||
file_idx: usize,
|
||||
builder: &BigramIndexBuilder,
|
||||
skip_builder: &BigramIndexBuilder,
|
||||
base_fd: i32,
|
||||
base_path: &std::path::Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
budget: &crate::types::ContentCacheBudget,
|
||||
) -> FileOutcome {
|
||||
if file.is_binary() || file.size == 0 || file.size > budget.max_file_size {
|
||||
return FileOutcome::Skipped;
|
||||
}
|
||||
|
||||
// Zero-copy fast path: the warmup phase may have cached this file's
|
||||
// content already. Avoid re-reading from disk.
|
||||
if let Some(cached) = file.get_content(arena, base_path, budget) {
|
||||
if crate::file_picker::detect_binary_content(cached) {
|
||||
return FileOutcome::Binary;
|
||||
}
|
||||
let capped = &cached[..cached.len().min(BIGRAM_CONTENT_CAP)];
|
||||
builder.add_file_content(skip_builder, file_idx, capped);
|
||||
return FileOutcome::Indexed;
|
||||
}
|
||||
|
||||
let want = (file.size as usize).min(BIGRAM_CONTENT_CAP);
|
||||
let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
|
||||
|
||||
READ_BUF.with(|read_cell| {
|
||||
let mut buf = read_cell.borrow_mut();
|
||||
let filled = read_file_content(
|
||||
file,
|
||||
base_fd,
|
||||
base_path,
|
||||
arena,
|
||||
&mut path_buf,
|
||||
&mut buf[..want],
|
||||
);
|
||||
if filled == 0 {
|
||||
return FileOutcome::Skipped;
|
||||
}
|
||||
let data = &buf[..filled];
|
||||
if crate::file_picker::detect_binary_content(data) {
|
||||
return FileOutcome::Binary;
|
||||
}
|
||||
builder.add_file_content(skip_builder, file_idx, data);
|
||||
FileOutcome::Indexed
|
||||
})
|
||||
}
|
||||
|
||||
/// Read up to `buf.len()` bytes of `file`'s content into `buf`. Returns
|
||||
/// the number of bytes actually read (0 on any error, so callers treat
|
||||
/// failures as "skip").
|
||||
#[inline]
|
||||
fn read_file_content(
|
||||
file: &crate::types::FileItem,
|
||||
base_fd: i32,
|
||||
base_path: &std::path::Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
path_buf: &mut [u8; crate::simd_path::PATH_BUF_SIZE],
|
||||
buf: &mut [u8],
|
||||
) -> usize {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
read_file_content_unix(file, base_fd, base_path, arena, path_buf, buf)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = base_fd;
|
||||
read_file_content_std(file, base_path, arena, path_buf, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_file_content_unix(
|
||||
file: &crate::types::FileItem,
|
||||
base_fd: libc::c_int,
|
||||
base_path: &std::path::Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
path_buf: &mut [u8; crate::simd_path::PATH_BUF_SIZE],
|
||||
buf: &mut [u8],
|
||||
) -> usize {
|
||||
let fd = if base_fd >= 0 {
|
||||
let rel_cstr = file.write_relative_cstr(arena, path_buf);
|
||||
// SAFETY: `rel_cstr` is NUL-terminated, `base_fd` is a valid
|
||||
// directory descriptor owned by the caller.
|
||||
unsafe { libc::openat(base_fd, rel_cstr.as_ptr(), libc::O_RDONLY) }
|
||||
} else {
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
let abs = file.write_absolute_path(arena, base_path, path_buf);
|
||||
match std::fs::File::open(abs) {
|
||||
Ok(f) => f.into_raw_fd(),
|
||||
Err(_) => return 0,
|
||||
}
|
||||
};
|
||||
if fd < 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut filled = 0usize;
|
||||
while filled < buf.len() {
|
||||
// SAFETY: `fd` is an owned descriptor, `buf[filled..]` is a
|
||||
// valid writable slice for `buf.len() - filled` bytes.
|
||||
let n = unsafe {
|
||||
libc::read(
|
||||
fd,
|
||||
buf[filled..].as_mut_ptr() as *mut libc::c_void,
|
||||
(buf.len() - filled) as libc::size_t,
|
||||
)
|
||||
};
|
||||
if n <= 0 {
|
||||
break;
|
||||
}
|
||||
filled += n as usize;
|
||||
}
|
||||
// SAFETY: matching close for the owned descriptor.
|
||||
unsafe { libc::close(fd) };
|
||||
filled
|
||||
}
|
||||
|
||||
/// Open the base directory for the `openat` fast path. Returns `-1` on
|
||||
/// failure — callers interpret a negative fd as "fall back to absolute
|
||||
/// paths".
|
||||
#[cfg(unix)]
|
||||
fn open_base_dir_fd(base_path: &std::path::Path) -> libc::c_int {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let mut cstr = [0u8; crate::simd_path::PATH_BUF_SIZE];
|
||||
let bytes = base_path.as_os_str().as_bytes();
|
||||
if bytes.len() >= cstr.len() {
|
||||
return -1;
|
||||
}
|
||||
cstr[..bytes.len()].copy_from_slice(bytes);
|
||||
// SAFETY: `cstr` is NUL-terminated by construction (zero-initialised,
|
||||
// and we only filled up to `bytes.len() < cstr.len()`).
|
||||
unsafe {
|
||||
libc::open(
|
||||
cstr.as_ptr() as *const std::os::raw::c_char,
|
||||
libc::O_RDONLY | libc::O_DIRECTORY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Portable fallback (Windows + non-`openat` Unix): `std::fs::File` +
|
||||
/// `Read::read` into `buf`. Used on Windows unconditionally, and on
|
||||
/// Unix when the base directory fd could not be opened.
|
||||
#[cfg(not(unix))]
|
||||
fn read_file_content_std(
|
||||
file: &crate::types::FileItem,
|
||||
base_path: &std::path::Path,
|
||||
arena: crate::simd_path::ArenaPtr,
|
||||
path_buf: &mut [u8; crate::simd_path::PATH_BUF_SIZE],
|
||||
buf: &mut [u8],
|
||||
) -> usize {
|
||||
use std::io::Read;
|
||||
let abs = file.write_absolute_path(arena, base_path, path_buf);
|
||||
let Ok(mut f) = std::fs::File::open(abs) else {
|
||||
return 0;
|
||||
};
|
||||
let mut filled = 0usize;
|
||||
while filled < buf.len() {
|
||||
match f.read(&mut buf[filled..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => filled += n,
|
||||
Err(_) => return 0,
|
||||
}
|
||||
}
|
||||
filled
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a key the same way `add_file_content` does: two printable-ASCII
|
||||
/// bytes, lowercased, packed as `(hi << 8) | lo`.
|
||||
fn key(a: u8, b: u8) -> u16 {
|
||||
((a.to_ascii_lowercase() as u16) << 8) | b.to_ascii_lowercase() as u16
|
||||
}
|
||||
|
||||
/// Return the sorted list of (consec, skip) bigram keys that should appear
|
||||
/// for `content`. Used as the reference implementation.
|
||||
fn expected_bigrams(content: &[u8]) -> (Vec<u16>, Vec<u16>) {
|
||||
let mut consec: std::collections::BTreeSet<u16> = Default::default();
|
||||
let mut skip: std::collections::BTreeSet<u16> = Default::default();
|
||||
let printable = |b: u8| (32..=126).contains(&b);
|
||||
for i in 1..content.len() {
|
||||
let a = content[i - 1];
|
||||
let b = content[i];
|
||||
if printable(a) && printable(b) {
|
||||
consec.insert(key(a, b));
|
||||
}
|
||||
if i >= 2 {
|
||||
let a = content[i - 2];
|
||||
let b = content[i];
|
||||
if printable(a) && printable(b) {
|
||||
skip.insert(key(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
(consec.into_iter().collect(), skip.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Query: does the builder record file 0 as having this bigram set?
|
||||
fn builder_has_key_for_file_0(b: &BigramIndexBuilder, k: u16) -> bool {
|
||||
let col = b.lookup[k as usize].load(Ordering::Relaxed);
|
||||
if col == NO_COLUMN {
|
||||
return false;
|
||||
}
|
||||
b.column_bitset(col)[0] & 1 != 0
|
||||
}
|
||||
|
||||
fn run_and_compare(content: &[u8]) {
|
||||
let consec = BigramIndexBuilder::new(1);
|
||||
let skip = BigramIndexBuilder::new(1);
|
||||
consec.add_file_content(&skip, 0, content);
|
||||
|
||||
let (expected_consec, expected_skip) = expected_bigrams(content);
|
||||
|
||||
// Every expected bigram must be recorded.
|
||||
for k in &expected_consec {
|
||||
assert!(
|
||||
builder_has_key_for_file_0(&consec, *k),
|
||||
"consec bigram 0x{k:04x} missing for content {content:?}",
|
||||
);
|
||||
}
|
||||
for k in &expected_skip {
|
||||
assert!(
|
||||
builder_has_key_for_file_0(&skip, *k),
|
||||
"skip bigram 0x{k:04x} missing for content {content:?}",
|
||||
);
|
||||
}
|
||||
|
||||
// No unexpected bigrams — iterate lookup for set columns.
|
||||
for k in 0u32..=0xFFFF {
|
||||
let recorded_consec = builder_has_key_for_file_0(&consec, k as u16);
|
||||
let recorded_skip = builder_has_key_for_file_0(&skip, k as u16);
|
||||
if recorded_consec {
|
||||
assert!(
|
||||
expected_consec.contains(&(k as u16)),
|
||||
"unexpected consec bigram 0x{k:04x} in content {content:?}",
|
||||
);
|
||||
}
|
||||
if recorded_skip {
|
||||
assert!(
|
||||
expected_skip.contains(&(k as u16)),
|
||||
"unexpected skip bigram 0x{k:04x} in content {content:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing overflow entry's bigrams.
|
||||
pub(crate) fn update_added(&mut self, idx: usize, bigrams: Vec<u16>) {
|
||||
if idx < self.added.len() {
|
||||
self.added[idx] = bigrams;
|
||||
#[test]
|
||||
fn add_file_empty_is_noop() {
|
||||
let consec = BigramIndexBuilder::new(1);
|
||||
let skip = BigramIndexBuilder::new(1);
|
||||
consec.add_file_content(&skip, 0, b"");
|
||||
assert_eq!(consec.columns_used(), 0);
|
||||
assert_eq!(skip.columns_used(), 0);
|
||||
// populated counter not incremented for empty input
|
||||
assert_eq!(consec.populated.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_single_byte_is_noop() {
|
||||
let consec = BigramIndexBuilder::new(1);
|
||||
let skip = BigramIndexBuilder::new(1);
|
||||
consec.add_file_content(&skip, 0, b"a");
|
||||
assert_eq!(consec.columns_used(), 0);
|
||||
assert_eq!(skip.columns_used(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_two_bytes_consec_only() {
|
||||
// With exactly 2 bytes there's no skip bigram (needs i >= 2 in the loop).
|
||||
run_and_compare(b"ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_three_bytes_has_skip() {
|
||||
// "abc" -> consec {"ab", "bc"}, skip {"ac"}
|
||||
run_and_compare(b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_ascii_words() {
|
||||
run_and_compare(b"hello world");
|
||||
run_and_compare(b"the quick brown fox jumps over the lazy dog");
|
||||
run_and_compare(b"fn main() { println!(\"hi\"); }");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_case_is_lowered() {
|
||||
// Uppercase should be lowercased before keying, so "AB" == "ab".
|
||||
let upper = BigramIndexBuilder::new(1);
|
||||
let upper_skip = BigramIndexBuilder::new(1);
|
||||
upper.add_file_content(&upper_skip, 0, b"ABC");
|
||||
|
||||
let lower = BigramIndexBuilder::new(1);
|
||||
let lower_skip = BigramIndexBuilder::new(1);
|
||||
lower.add_file_content(&lower_skip, 0, b"abc");
|
||||
|
||||
// Both should have identical bigram keys.
|
||||
for k in 0u32..=0xFFFF {
|
||||
let u = builder_has_key_for_file_0(&upper, k as u16);
|
||||
let l = builder_has_key_for_file_0(&lower, k as u16);
|
||||
assert_eq!(u, l, "consec 0x{k:04x}: upper={u} lower={l}");
|
||||
let u = builder_has_key_for_file_0(&upper_skip, k as u16);
|
||||
let l = builder_has_key_for_file_0(&lower_skip, k as u16);
|
||||
assert_eq!(u, l, "skip 0x{k:04x}: upper={u} lower={l}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_rejects_non_printable() {
|
||||
// Bigrams where either byte is outside 32..=126 are rejected. But
|
||||
// the skip-1 bigram can still connect two printable bytes across a
|
||||
// non-printable one: for "\0a\0b", consec sees no valid pair but
|
||||
// skip sees (a,b) at i=3. Use the reference implementation.
|
||||
run_and_compare(b"\0a\0b");
|
||||
|
||||
// All-zero input: truly nothing recorded.
|
||||
let consec = BigramIndexBuilder::new(1);
|
||||
let skip = BigramIndexBuilder::new(1);
|
||||
consec.add_file_content(&skip, 0, b"\0\0\0\0");
|
||||
assert_eq!(consec.columns_used(), 0);
|
||||
assert_eq!(skip.columns_used(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_mixed_printable_and_control() {
|
||||
// "a\tb\nc d" — \t (9) and \n (10) are below 32. Consec:
|
||||
// (a, \t) x, (\t, b) x, (b, \n) x, (\n, c) x, (c, ' ') ok, (' ', d) ok
|
||||
// Skip (i-2, i):
|
||||
// (a, b) ok, (\t, \n) x, (b, c) ok, (\n, ' ') x, (c, d) ok
|
||||
run_and_compare(b"a\tb\nc d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_repeats_are_deduped() {
|
||||
// "ababab" has many repeats of "ab", "ba" — each unique bigram should
|
||||
// be recorded exactly once (the stack-local `seen_*` dedup works).
|
||||
run_and_compare(b"ababababab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_tombstone_separation() {
|
||||
// Two separate files share no bits; file 1's content doesn't bleed
|
||||
// into file 0's row and vice-versa.
|
||||
let consec = BigramIndexBuilder::new(2);
|
||||
let skip = BigramIndexBuilder::new(2);
|
||||
consec.add_file_content(&skip, 0, b"xy");
|
||||
consec.add_file_content(&skip, 1, b"zw");
|
||||
|
||||
let key_xy = key(b'x', b'y');
|
||||
let key_zw = key(b'z', b'w');
|
||||
|
||||
// file 0 has "xy" but not "zw"
|
||||
let col_xy = consec.lookup[key_xy as usize].load(Ordering::Relaxed);
|
||||
let col_zw = consec.lookup[key_zw as usize].load(Ordering::Relaxed);
|
||||
let bitset_xy = consec.column_bitset(col_xy)[0];
|
||||
let bitset_zw = consec.column_bitset(col_zw)[0];
|
||||
assert_eq!(bitset_xy & 0b01, 0b01, "file 0 should have xy");
|
||||
assert_eq!(bitset_zw & 0b01, 0, "file 0 should NOT have zw");
|
||||
assert_eq!(bitset_xy & 0b10, 0, "file 1 should NOT have xy");
|
||||
assert_eq!(bitset_zw & 0b10, 0b10, "file 1 should have zw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_long_content() {
|
||||
// Stress test: ~8 KB of printable ASCII. Should complete without
|
||||
// overflowing any stack-local bitset and produce the full set.
|
||||
let mut buf = Vec::with_capacity(8192);
|
||||
for i in 0..8192 {
|
||||
buf.push(32u8 + ((i * 7) % 95) as u8); // cycle through printable range
|
||||
}
|
||||
run_and_compare(&buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_simd_and_scalar_agree() {
|
||||
// Cross-check: both code paths (scalar <128 bytes, SIMD ≥128) must
|
||||
// produce identical bigram sets for content that straddles the
|
||||
// threshold. Mix printable ASCII with some non-printable bytes and
|
||||
// repeats so the non-printable branch in the SIMD path exercises.
|
||||
let mut mixed = Vec::with_capacity(256);
|
||||
for i in 0..256usize {
|
||||
mixed.push(match i % 9 {
|
||||
0 => 0, // NUL
|
||||
1 => 0x7F, // DEL (just above 126)
|
||||
2 => b'\n', // below 32
|
||||
_ => 32 + ((i * 13) % 95) as u8,
|
||||
});
|
||||
}
|
||||
|
||||
run_and_compare(&mixed[..127]); // scalar path
|
||||
run_and_compare(&mixed); // SIMD path (256 bytes)
|
||||
run_and_compare(&mixed[..192]); // SIMD path with scalar tail
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_file_respects_file_count_boundary() {
|
||||
// file_count=100, file_idx=63 (last bit in word 0) and file_idx=64
|
||||
// (first bit in word 1). Make sure the word_idx math is right.
|
||||
let consec = BigramIndexBuilder::new(100);
|
||||
let skip = BigramIndexBuilder::new(100);
|
||||
consec.add_file_content(&skip, 63, b"ab");
|
||||
consec.add_file_content(&skip, 64, b"cd");
|
||||
|
||||
let kab = key(b'a', b'b');
|
||||
let kcd = key(b'c', b'd');
|
||||
let col_ab = consec.lookup[kab as usize].load(Ordering::Relaxed);
|
||||
let col_cd = consec.lookup[kcd as usize].load(Ordering::Relaxed);
|
||||
|
||||
let ab_bitset = consec.column_bitset(col_ab);
|
||||
let cd_bitset = consec.column_bitset(col_cd);
|
||||
// ab in word 0, bit 63
|
||||
assert_eq!(ab_bitset[0], 1u64 << 63);
|
||||
assert_eq!(ab_bitset[1], 0);
|
||||
// cd in word 1, bit 0
|
||||
assert_eq!(cd_bitset[0], 0);
|
||||
assert_eq!(cd_bitset[1], 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
//! Regex → bigram decomposition for the inverted bigram index.
|
||||
//!
|
||||
//! Parses a regex pattern with `regex-syntax`, walks the HIR to extract
|
||||
//! guaranteed bigram keys (u16), and evaluates them as an AND/OR query tree
|
||||
//! against [`BigramFilter`]'s inverted posting lists.
|
||||
//!
|
||||
//! Two bigram types are extracted:
|
||||
//! - **Consecutive** (gap=0): adjacent byte pairs `(pattern[i], pattern[i+1])`
|
||||
//! - **Sparse-1** (gap=1): pairs across a single-byte wildcard, e.g. `a.b → (a,b)`
|
||||
//!
|
||||
//! The sparse-1 extraction is the key feature: regex patterns like `foo.bar`
|
||||
//! yield the cross-boundary sparse-1 bigram `(o,b)` that provides strong
|
||||
//! filtering even when the `.` prevents any consecutive cross-boundary bigram.
|
||||
|
||||
use crate::bigram_filter::BigramFilter;
|
||||
use regex_syntax::hir::{Class, Hir, HirKind};
|
||||
use smallvec::SmallVec;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Maximum byte values to enumerate from a character class.
|
||||
/// Larger classes are treated as unknown (no bigram extractable).
|
||||
const MAX_CLASS_EXPAND: usize = 16;
|
||||
|
||||
#[inline]
|
||||
fn consec_key(a: u8, b: u8) -> Option<u16> {
|
||||
let al = a.to_ascii_lowercase();
|
||||
let bl = b.to_ascii_lowercase();
|
||||
if (32..=126).contains(&al) && (32..=126).contains(&bl) {
|
||||
Some((al as u16) << 8 | bl as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BigramQuery {
|
||||
Any,
|
||||
/// A consecutive bigram key to look up in the main index.
|
||||
Consec(u16),
|
||||
/// A skip-1 bigram key to look up in the skip sub-index.
|
||||
Skip1(u16),
|
||||
/// All children must match (intersect posting lists).
|
||||
And(Vec<BigramQuery>),
|
||||
/// At least one child must match (union posting lists).
|
||||
Or(Vec<BigramQuery>),
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise OR of two equal-length bitsets.
|
||||
#[inline]
|
||||
fn bitset_or(a: &mut [u64], b: &[u64]) {
|
||||
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x |= *y);
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise AND of two equal-length bitsets.
|
||||
#[inline]
|
||||
fn bitset_and(a: &mut [u64], b: &[u64]) {
|
||||
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x &= *y);
|
||||
}
|
||||
|
||||
impl BigramQuery {
|
||||
pub fn is_any(&self) -> bool {
|
||||
matches!(self, BigramQuery::Any)
|
||||
}
|
||||
|
||||
pub(crate) fn evaluate(&self, index: &BigramFilter) -> Option<Vec<u64>> {
|
||||
self.evaluate_cow(index).map(Cow::into_owned)
|
||||
}
|
||||
|
||||
fn evaluate_cow<'a>(&self, index: &'a BigramFilter) -> Option<Cow<'a, [u64]>> {
|
||||
match self {
|
||||
BigramQuery::Any => None,
|
||||
|
||||
BigramQuery::Consec(key) => {
|
||||
let col = index.lookup()[*key as usize];
|
||||
if col == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
let words = index.words();
|
||||
let offset = col as usize * words;
|
||||
let data = index.dense_data();
|
||||
if offset + words > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Cow::Borrowed(&data[offset..offset + words]))
|
||||
}
|
||||
|
||||
BigramQuery::Skip1(key) => {
|
||||
let skip = index.skip_index()?;
|
||||
let col = skip.lookup()[*key as usize];
|
||||
if col == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
let words = skip.words();
|
||||
let offset = col as usize * words;
|
||||
let data = skip.dense_data();
|
||||
if offset + words > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Cow::Borrowed(&data[offset..offset + words]))
|
||||
}
|
||||
|
||||
BigramQuery::And(children) => {
|
||||
let mut result: Option<Vec<u64>> = None;
|
||||
for child in children {
|
||||
if let Some(child_bits) = child.evaluate_cow(index) {
|
||||
result = Some(match result {
|
||||
None => child_bits.into_owned(),
|
||||
Some(mut r) => {
|
||||
bitset_and(&mut r, &child_bits);
|
||||
r
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
result.map(Cow::Owned)
|
||||
}
|
||||
|
||||
BigramQuery::Or(children) => {
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut result: Option<Vec<u64>> = None;
|
||||
for child in children {
|
||||
match child.evaluate_cow(index) {
|
||||
// Any branch can't be filtered → whole OR can't be filtered
|
||||
None => return None,
|
||||
Some(child_bits) => {
|
||||
result = Some(match result {
|
||||
None => child_bits.into_owned(),
|
||||
Some(mut r) => {
|
||||
bitset_or(&mut r, &child_bits);
|
||||
r
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result.map(Cow::Owned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Intermediate state tracked during HIR traversal for bigram extraction.
|
||||
struct HirInfo {
|
||||
query: BigramQuery,
|
||||
/// Possible first bytes (lowercased, printable ASCII) when this node matches.
|
||||
first: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
|
||||
/// Possible last bytes.
|
||||
last: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
|
||||
/// Whether this node can match the empty string.
|
||||
can_be_empty: bool,
|
||||
}
|
||||
|
||||
impl HirInfo {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
query: BigramQuery::Any,
|
||||
first: None,
|
||||
last: None,
|
||||
can_be_empty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefilter fuzzy query. The algorithm is the following:
|
||||
/// we allow max_typos = min(len/3,2) every typo destroys at most 2 consecutive bigrams
|
||||
/// So out of N bigrams at least N - 2 * max_typos have to present in the matching fil
|
||||
pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQuery {
|
||||
let lower: Vec<u8> = query.bytes().map(|b| b.to_ascii_lowercase()).collect();
|
||||
|
||||
if lower.len() < 2 {
|
||||
return BigramQuery::Any;
|
||||
}
|
||||
|
||||
let max_typos = (lower.len() / 3).min(2);
|
||||
|
||||
// Extract all consecutive bigram keys.
|
||||
let bigram_keys: Vec<u16> = lower
|
||||
.windows(2)
|
||||
.filter_map(|w| consec_key(w[0], w[1]))
|
||||
.collect();
|
||||
|
||||
if bigram_keys.is_empty() {
|
||||
return BigramQuery::Any;
|
||||
}
|
||||
|
||||
// For very short queries (0 typos), AND all bigrams — exact subsequence.
|
||||
if max_typos == 0 {
|
||||
return simplify_and(
|
||||
bigram_keys
|
||||
.iter()
|
||||
.map(|&k| BigramQuery::Consec(k))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
// Pick evenly-spaced probe bigrams.
|
||||
let n = num_probes.min(bigram_keys.len());
|
||||
if n <= max_typos {
|
||||
// Too few probes to require anything useful.
|
||||
return simplify_or(
|
||||
bigram_keys
|
||||
.iter()
|
||||
.map(|&k| BigramQuery::Consec(k))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
let probes: Vec<u16> = if n == bigram_keys.len() {
|
||||
bigram_keys
|
||||
} else {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let idx = i * (bigram_keys.len() - 1) / (n - 1);
|
||||
bigram_keys[idx]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let required = n - max_typos;
|
||||
|
||||
// If required == n, just AND all probes.
|
||||
if required >= n {
|
||||
return simplify_and(probes.iter().map(|&k| BigramQuery::Consec(k)).collect());
|
||||
}
|
||||
|
||||
// Generate all C(n, required) subsets → OR(AND(subset), ...)
|
||||
let mut branches = Vec::new();
|
||||
let mut combo = vec![0u16; required];
|
||||
combine(&probes, required, 0, 0, &mut combo, &mut branches);
|
||||
|
||||
simplify_or(branches)
|
||||
}
|
||||
|
||||
/// Build C(n, k) combination branches in-place on a fixed-size slice.
|
||||
fn combine(
|
||||
items: &[u16],
|
||||
k: usize,
|
||||
start: usize,
|
||||
depth: usize,
|
||||
combo: &mut [u16],
|
||||
branches: &mut Vec<BigramQuery>,
|
||||
) {
|
||||
if depth == k {
|
||||
branches.push(simplify_and(
|
||||
combo.iter().map(|&key| BigramQuery::Consec(key)).collect(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
let remaining = k - depth;
|
||||
for i in start..=items.len() - remaining {
|
||||
combo[depth] = items[i];
|
||||
combine(items, k, i + 1, depth + 1, combo, branches);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn regex_to_bigram_query(pattern: &str) -> BigramQuery {
|
||||
let mut parser = regex_syntax::ParserBuilder::new()
|
||||
.unicode(false)
|
||||
.utf8(false)
|
||||
.build();
|
||||
|
||||
let hir = match parser.parse(pattern) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return BigramQuery::Any,
|
||||
};
|
||||
|
||||
decompose(&hir).query
|
||||
}
|
||||
|
||||
fn decompose(hir: &Hir) -> HirInfo {
|
||||
let can_be_empty = hir.properties().minimum_len().is_none_or(|n| n == 0);
|
||||
|
||||
match hir.kind() {
|
||||
HirKind::Empty => HirInfo::empty(),
|
||||
|
||||
HirKind::Literal(lit) => decompose_literal(lit.0.as_ref()),
|
||||
|
||||
HirKind::Class(class) => {
|
||||
let bytes = expand_class(class);
|
||||
match bytes {
|
||||
Some(b) if !b.is_empty() => HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: Some(b.clone()),
|
||||
last: Some(b),
|
||||
can_be_empty,
|
||||
},
|
||||
_ => HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: None,
|
||||
last: None,
|
||||
can_be_empty,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
HirKind::Look(_) => HirInfo::empty(),
|
||||
|
||||
HirKind::Repetition(rep) => {
|
||||
let inner = decompose(&rep.sub);
|
||||
if rep.min == 0 {
|
||||
HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: inner.first,
|
||||
last: inner.last,
|
||||
can_be_empty: true,
|
||||
}
|
||||
} else {
|
||||
// min >= 1: inner bigrams guaranteed
|
||||
let mut qs = Vec::new();
|
||||
if !inner.query.is_any() {
|
||||
qs.push(inner.query.clone());
|
||||
}
|
||||
// min >= 2: cross-boundary between consecutive occurrences
|
||||
if rep.min >= 2 {
|
||||
push_cross_consec(&mut qs, inner.last.as_deref(), inner.first.as_deref());
|
||||
}
|
||||
HirInfo {
|
||||
query: simplify_and(qs),
|
||||
first: inner.first,
|
||||
last: inner.last,
|
||||
can_be_empty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HirKind::Capture(cap) => decompose(&cap.sub),
|
||||
|
||||
HirKind::Concat(parts) => decompose_concat(parts),
|
||||
|
||||
HirKind::Alternation(alts) => decompose_alternation(alts),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract bigrams from a literal byte sequence.
|
||||
fn decompose_literal(bytes: &[u8]) -> HirInfo {
|
||||
if bytes.is_empty() {
|
||||
return HirInfo::empty();
|
||||
}
|
||||
|
||||
let lower: SmallVec<[u8; 64]> = bytes.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
|
||||
if lower.len() == 1 {
|
||||
let b = lower[0];
|
||||
let first = if (32..=126).contains(&b) {
|
||||
Some(SmallVec::from_slice(&[b]))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return HirInfo {
|
||||
query: BigramQuery::Any,
|
||||
first: first.clone(),
|
||||
last: first,
|
||||
can_be_empty: false,
|
||||
};
|
||||
}
|
||||
|
||||
let mut qs: Vec<BigramQuery> = Vec::new();
|
||||
|
||||
// Consecutive bigrams
|
||||
for w in lower.windows(2) {
|
||||
if let Some(k) = consec_key(w[0], w[1]) {
|
||||
qs.push(BigramQuery::Consec(k));
|
||||
}
|
||||
}
|
||||
|
||||
// Skip-1 bigrams from the literal itself
|
||||
if lower.len() >= 3 {
|
||||
for i in 0..lower.len() - 2 {
|
||||
if let Some(k) = consec_key(lower[i], lower[i + 2]) {
|
||||
qs.push(BigramQuery::Skip1(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first_byte = lower[0];
|
||||
let last_byte = *lower.last().unwrap();
|
||||
|
||||
HirInfo {
|
||||
query: simplify_and(qs),
|
||||
first: if (32..=126).contains(&first_byte) {
|
||||
Some(SmallVec::from_slice(&[first_byte]))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
last: if (32..=126).contains(&last_byte) {
|
||||
Some(SmallVec::from_slice(&[last_byte]))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
can_be_empty: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn decompose_concat(parts: &[Hir]) -> HirInfo {
|
||||
if parts.is_empty() {
|
||||
return HirInfo::empty();
|
||||
}
|
||||
|
||||
let infos: Vec<HirInfo> = parts.iter().map(decompose).collect();
|
||||
let mut qs: Vec<BigramQuery> = Vec::new();
|
||||
|
||||
// 1. Collect child bigrams
|
||||
for info in &infos {
|
||||
if !info.query.is_any() {
|
||||
qs.push(info.query.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dense cross-boundary between adjacent mandatory parts
|
||||
for pair in infos.windows(2) {
|
||||
if !pair[0].can_be_empty && !pair[1].can_be_empty {
|
||||
push_cross_consec(&mut qs, pair[0].last.as_deref(), pair[1].first.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sparse-1 cross-boundary: across a single 1-byte-wide middle part.
|
||||
// Catches `foo.bar` → sparse-1 `(o,b)` across the dot.
|
||||
if parts.len() >= 3 {
|
||||
for i in 0..parts.len() - 2 {
|
||||
let left = &infos[i];
|
||||
let mid = &parts[i + 1];
|
||||
let right = &infos[i + 2];
|
||||
|
||||
let min_len = mid.properties().minimum_len();
|
||||
let max_len = mid.properties().maximum_len();
|
||||
let is_1byte = min_len == Some(1) && max_len == Some(1);
|
||||
|
||||
if is_1byte && !left.can_be_empty && !right.can_be_empty {
|
||||
push_cross_skip1(&mut qs, left.last.as_deref(), right.first.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first = collect_first(&infos);
|
||||
let last = collect_last(&infos);
|
||||
let can_be_empty = infos.iter().all(|i| i.can_be_empty);
|
||||
|
||||
HirInfo {
|
||||
query: simplify_and(qs),
|
||||
first,
|
||||
last,
|
||||
can_be_empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn decompose_alternation(alts: &[Hir]) -> HirInfo {
|
||||
if alts.is_empty() {
|
||||
return HirInfo::empty();
|
||||
}
|
||||
|
||||
let infos: Vec<HirInfo> = alts.iter().map(decompose).collect();
|
||||
let query = simplify_or(infos.iter().map(|i| i.query.clone()).collect());
|
||||
let first = merge_byte_sets(infos.iter().map(|i| &i.first));
|
||||
let last = merge_byte_sets(infos.iter().map(|i| &i.last));
|
||||
let can_be_empty = infos.iter().any(|i| i.can_be_empty);
|
||||
|
||||
HirInfo {
|
||||
query,
|
||||
first,
|
||||
last,
|
||||
can_be_empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_class(class: &Class) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut bytes: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
match class {
|
||||
Class::Bytes(bc) => {
|
||||
for range in bc.ranges() {
|
||||
let count = (range.end() as usize) - (range.start() as usize) + 1;
|
||||
if bytes.len() + count > MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
for b in range.start()..=range.end() {
|
||||
if (32..=126).contains(&b) {
|
||||
let lower = b.to_ascii_lowercase();
|
||||
if !bytes.contains(&lower) {
|
||||
bytes.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Class::Unicode(uc) => {
|
||||
for range in uc.ranges() {
|
||||
let start = range.start() as u32;
|
||||
let end = range.end() as u32;
|
||||
if start > 127 {
|
||||
continue;
|
||||
}
|
||||
let ascii_end = end.min(126) as u8;
|
||||
let ascii_start = start.max(32) as u8;
|
||||
if ascii_start > ascii_end {
|
||||
continue;
|
||||
}
|
||||
let count = (ascii_end - ascii_start) as usize + 1;
|
||||
if bytes.len() + count > MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
for b in ascii_start..=ascii_end {
|
||||
let lower = b.to_ascii_lowercase();
|
||||
if !bytes.contains(&lower) {
|
||||
bytes.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() { None } else { Some(bytes) }
|
||||
}
|
||||
|
||||
/// Push consecutive cross-product bigrams into `qs`.
|
||||
fn push_cross_consec(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
|
||||
if let Some(q) = cross_product(last, first, false) {
|
||||
qs.push(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push skip-1 cross-product bigrams into `qs`.
|
||||
fn push_cross_skip1(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
|
||||
if let Some(q) = cross_product(last, first, true) {
|
||||
qs.push(q);
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_product(last: Option<&[u8]>, first: Option<&[u8]>, skip: bool) -> Option<BigramQuery> {
|
||||
let last = last?;
|
||||
let first = first?;
|
||||
let n = last.len() * first.len();
|
||||
if n == 0 || n > MAX_CLASS_EXPAND * MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut bigrams: Vec<BigramQuery> = Vec::with_capacity(n);
|
||||
for &l in last {
|
||||
for &f in first {
|
||||
if let Some(k) = consec_key(l, f) {
|
||||
let node = if skip {
|
||||
BigramQuery::Skip1(k)
|
||||
} else {
|
||||
BigramQuery::Consec(k)
|
||||
};
|
||||
bigrams.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match bigrams.len() {
|
||||
0 => None,
|
||||
1 => Some(bigrams.into_iter().next().unwrap()),
|
||||
_ => Some(simplify_or(bigrams)),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_first(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
for info in infos {
|
||||
if let Some(ref bytes) = info.first {
|
||||
for &b in bytes {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
}
|
||||
} else if !info.can_be_empty {
|
||||
return None;
|
||||
}
|
||||
if !info.can_be_empty {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_last(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
for info in infos.iter().rev() {
|
||||
if let Some(ref bytes) = info.last {
|
||||
for &b in bytes {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
}
|
||||
} else if !info.can_be_empty {
|
||||
return None;
|
||||
}
|
||||
if !info.can_be_empty {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_byte_sets<'a>(
|
||||
iter: impl Iterator<Item = &'a Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>>,
|
||||
) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
|
||||
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
|
||||
for opt in iter {
|
||||
match opt {
|
||||
None => return None,
|
||||
Some(bytes) => {
|
||||
for &b in bytes {
|
||||
if !result.contains(&b) {
|
||||
if result.len() >= MAX_CLASS_EXPAND {
|
||||
return None;
|
||||
}
|
||||
result.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn simplify_and(children: Vec<BigramQuery>) -> BigramQuery {
|
||||
let mut flat: Vec<BigramQuery> = Vec::new();
|
||||
for child in children {
|
||||
match child {
|
||||
BigramQuery::Any => {}
|
||||
BigramQuery::And(inner) => flat.extend(inner),
|
||||
other => flat.push(other),
|
||||
}
|
||||
}
|
||||
match flat.len() {
|
||||
0 => BigramQuery::Any,
|
||||
1 => flat.into_iter().next().unwrap(),
|
||||
_ => BigramQuery::And(flat),
|
||||
}
|
||||
}
|
||||
|
||||
fn simplify_or(children: Vec<BigramQuery>) -> BigramQuery {
|
||||
if children.iter().any(|c| c.is_any()) {
|
||||
return BigramQuery::Any;
|
||||
}
|
||||
let mut flat: Vec<BigramQuery> = Vec::new();
|
||||
for child in children {
|
||||
match child {
|
||||
BigramQuery::Or(inner) => flat.extend(inner),
|
||||
other => flat.push(other),
|
||||
}
|
||||
}
|
||||
match flat.len() {
|
||||
0 => BigramQuery::Any,
|
||||
1 => flat.into_iter().next().unwrap(),
|
||||
_ => BigramQuery::Or(flat),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bigram_filter::BigramIndexBuilder;
|
||||
|
||||
/// Build a tiny index from the given file contents for testing.
|
||||
fn build_test_index(files: &[&[u8]]) -> BigramFilter {
|
||||
let n = files.len();
|
||||
let consec_builder = BigramIndexBuilder::new(n);
|
||||
let skip_builder = BigramIndexBuilder::new(n);
|
||||
for (i, content) in files.iter().enumerate() {
|
||||
consec_builder.add_file_content(&skip_builder, i, content);
|
||||
}
|
||||
let mut idx = consec_builder.compress(Some(0));
|
||||
idx.set_skip_index(skip_builder.compress(Some(0)));
|
||||
idx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_pattern() {
|
||||
let idx = build_test_index(&[
|
||||
b"hello world", // 0: contains "hello"
|
||||
b"goodbye world", // 1: no "hello"
|
||||
b"say hello there", // 2: contains "hello"
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("hello");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternation() {
|
||||
let idx = build_test_index(&[
|
||||
b"has foo in it", // 0
|
||||
b"has bar in it", // 1
|
||||
b"has xyz in it", // 2
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("foo|bar");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
// xyz doesn't contain foo or bar bigrams
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_concat() {
|
||||
let idx = build_test_index(&[
|
||||
b"foo something bar", // 0
|
||||
b"foo only", // 1: has foo but not bar
|
||||
b"only bar", // 2: has bar but not foo
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("foo.*bar");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
// file 1 and 2 should be filtered (missing bigrams from "bar" / "foo")
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse1_across_dot() {
|
||||
// "a.b" should produce a skip-1 bigram (a,b)
|
||||
let idx = build_test_index(&[
|
||||
b"axb", // 0: has sparse-1 (a,b)
|
||||
b"ayb", // 1: has sparse-1 (a,b)
|
||||
b"xyz", // 2: no (a,b) at all
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("a.b");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse1_across_digit() {
|
||||
// "foo\dbar" → sparse-1 (o,b) across \d
|
||||
let idx = build_test_index(&[
|
||||
b"foo3bar baz", // 0: has all bigrams
|
||||
b"foobar baz", // 1: has consecutive (o,b) but pattern needs sparse-1
|
||||
b"xyz only", // 2: no relevant bigrams
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query(r"foo\dbar");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
// file 1 may or may not match depending on what bigrams are in the index
|
||||
// (it has all the literal bigrams and also o,b as both consec and skip-1)
|
||||
// The important thing is file 2 is excluded:
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_wildcard_is_any() {
|
||||
let q = regex_to_bigram_query(".*");
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_char_is_any() {
|
||||
let q = regex_to_bigram_query("a");
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_regex_is_any() {
|
||||
let q = regex_to_bigram_query("[invalid");
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_group_excluded() {
|
||||
// (bar)? is optional — its bigrams are not required
|
||||
let q = regex_to_bigram_query("foo(bar)?baz");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let idx = build_test_index(&[
|
||||
b"foobaz content", // 0: has foo+baz bigrams (bar absent)
|
||||
b"foobarbaz content", // 1: has everything
|
||||
b"xyz only", // 2: nothing
|
||||
]);
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repetition_min2_cross_boundary() {
|
||||
// (ab){2,} → bigram "ab" + cross-boundary "b","a"
|
||||
let q = regex_to_bigram_query("(ab){2,}");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let idx = build_test_index(&[
|
||||
b"ababab", // 0: has "ab" and "b"->"a"
|
||||
b"abonly", // 1: has "ab" but not "b"->"a"
|
||||
b"xyz", // 2: nothing
|
||||
]);
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_dots_no_sparse1() {
|
||||
// "a..b" — two 1-byte parts between a and b, not a single 1-byte part
|
||||
// No sparse-1 (a,b) should be extracted
|
||||
let q = regex_to_bigram_query("a..b");
|
||||
// Single-char literals with 2 unknown bytes between → Any
|
||||
assert!(q.is_any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_class_cross_boundary() {
|
||||
// [abc]de → cross-boundary OR(ad,bd,cd) + bigram de
|
||||
// All three class variants must appear in the corpus so the OR
|
||||
// branches are tracked in the index (untracked bigrams make the
|
||||
// OR conservatively return None, which is correct but untestable).
|
||||
let idx = build_test_index(&[
|
||||
b"ade content", // 0: has ad
|
||||
b"bde content", // 1: has bd
|
||||
b"cde content", // 2: has cd
|
||||
b"xde content", // 3: has de but not ad/bd/cd
|
||||
]);
|
||||
|
||||
let q = regex_to_bigram_query("[abc]de");
|
||||
assert!(!q.is_any());
|
||||
|
||||
let candidates = q.evaluate(&idx).unwrap();
|
||||
assert!(BigramFilter::is_candidate(&candidates, 0));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 1));
|
||||
assert!(BigramFilter::is_candidate(&candidates, 2));
|
||||
// file 3 doesn't have ad/bd/cd so should be filtered
|
||||
assert!(!BigramFilter::is_candidate(&candidates, 3));
|
||||
}
|
||||
|
||||
// ── Helpers for inspecting query trees ──────────────────────────
|
||||
|
||||
fn has_consec(q: &BigramQuery, a: u8, b: u8) -> bool {
|
||||
let Some(key) = consec_key(a, b) else {
|
||||
return false;
|
||||
};
|
||||
match q {
|
||||
BigramQuery::Consec(k) => *k == key,
|
||||
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_consec(c, a, b)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_skip1(q: &BigramQuery, a: u8, b: u8) -> bool {
|
||||
let Some(key) = consec_key(a, b) else {
|
||||
return false;
|
||||
};
|
||||
match q {
|
||||
BigramQuery::Skip1(k) => *k == key,
|
||||
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_skip1(c, a, b)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bigram expectation: `("ab", is_skip1)`.
|
||||
/// The 2-char str is the byte pair; C = consecutive, S = skip-1.
|
||||
type Bg = (&'static str, bool);
|
||||
const C: bool = false;
|
||||
const S: bool = true;
|
||||
|
||||
/// Top 15+ commonly used regex patterns from
|
||||
/// https://digitalfortress.tech/tips/top-15-commonly-used-regex/
|
||||
/// plus typical grep patterns used by agentic tools.
|
||||
///
|
||||
/// Each entry: `(regex, Option<&[Bg]>)`.
|
||||
/// - `None` → pure classes / unsupported syntax, Any is acceptable.
|
||||
/// - `Some(&[..])` → must be non-Any, and every listed bigram must appear.
|
||||
#[test]
|
||||
fn common_regex_patterns() {
|
||||
#[rustfmt::skip]
|
||||
let cases: &[(&str, Option<&[Bg]>)] = &[
|
||||
// ── Pure-class / anchor / unsupported → Any is fine ──────
|
||||
(r"^\d+$", None), // 1. whole numbers
|
||||
(r"^\d*\.\d+$", None), // 2. decimals
|
||||
(r"^\d*(\.\d+)?$", None), // 3. whole + decimal
|
||||
(r"^-?\d*(\.\d+)?$", None), // 4. neg/pos decimal
|
||||
(r"[-]?[0-9]+[,.]?[0-9]*([/][0-9]+[,.]?[0-9]*)*", None), // 5. fractions
|
||||
(r"^[a-zA-Z0-9]*$", None), // 6. alphanumeric
|
||||
(r"^[a-zA-Z0-9 ]*$", None), // 7. alphanum + space
|
||||
(r"^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$", None), // 8. email
|
||||
(r"^([a-z0-9_\.\+-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$", None), // 9. email v2
|
||||
(r"(?=(.*[0-9]))(?=.*[!@#$%^&*()\[\]{}\-_+=~`|:;<>,./?\x5c])(?=.*[a-z])(?=(.*[A-Z]))(?=(.*)).{8,}", None), // 10. complex pw
|
||||
(r"(?=(.*[0-9]))((?=.*[A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z]))^.{8,}$", None), // 11. moderate pw
|
||||
(r"^[a-z0-9_-]{3,16}$", None), // 12. username
|
||||
(r"(https?://)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", None), // 14. URL optional
|
||||
(r"^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$", None), // 15. IPv4
|
||||
(r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", None), // 16. IPv6
|
||||
(r"[12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])", None), // 17. date
|
||||
(r"^(0?[1-9]|1[0-2]):[0-5][0-9]$", None), // 18. time 12h
|
||||
(r"((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))", None), // 19. time AM/PM
|
||||
(r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 20. time 24h
|
||||
(r"^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 21. time 24h v2
|
||||
(r"(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)", None), // 22. time+sec
|
||||
(r"</?[\w\s]*>|<.+[\W]>", None), // 23. HTML tag
|
||||
(r"\bon\w+=\S+(?=.*>)", None), // 24. inline JS
|
||||
(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", None), // 25. slug
|
||||
(r"(\b\w+\b)(?=.*\b\1\b)", None), // 26. dup words
|
||||
(r"^[\w,\s-]+\.[A-Za-z]{3}$", None), // 27. filename
|
||||
(r"^[A-PR-WY][1-9]\d\s?\d{4}[1-9]$", None), // 28. HK ID
|
||||
|
||||
// ── Patterns with extractable literal bigrams ────────────
|
||||
|
||||
// 13. URL with required protocol
|
||||
(r"https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", Some(&[
|
||||
("ht", C), ("tt", C), ("tp", C), // from "http"
|
||||
("ht", S), ("tp", S), // from "http" skip-1
|
||||
(":/", C), ("//", C), // from "://"
|
||||
])),
|
||||
|
||||
// 29. fn\s+\w+
|
||||
(r"fn\s+\w+", Some(&[
|
||||
("fn", C), // from "fn"
|
||||
("n ", C), // cross-boundary: 'n' → \s starts ' '
|
||||
])),
|
||||
|
||||
// 30. use\s+crate::
|
||||
(r"use\s+crate::", Some(&[
|
||||
("us", C), ("se", C), ("ue", S), // from "use"
|
||||
("cr", C), ("ra", C), ("at", C), // from "crate"
|
||||
("te", C), ("::", C),
|
||||
("ca", S), ("rt", S), ("ae", S), // "crate" skip-1
|
||||
])),
|
||||
|
||||
// 31. unwrap\(\)|expect\(
|
||||
(r"unwrap\(\)|expect\(", Some(&[
|
||||
("nw", C), ("wr", C), ("ra", C), // "unwrap("
|
||||
("ap", C), ("p(", C),
|
||||
("xp", C), ("pe", C), ("ec", C), // "expect("
|
||||
("ct", C), ("t(", C),
|
||||
])),
|
||||
|
||||
// 32. TODO|FIXME|HACK
|
||||
(r"TODO|FIXME|HACK", Some(&[
|
||||
("to", C), ("od", C), ("do", C), // "TODO"
|
||||
("fi", C), ("ix", C), ("xm", C), // "FIXME"
|
||||
("me", C),
|
||||
("ha", C), ("ac", C), ("ck", C), // "HACK"
|
||||
("hc", S), ("ak", S), // "HACK" skip-1
|
||||
])),
|
||||
];
|
||||
|
||||
for (i, &(pattern, expected)) in cases.iter().enumerate() {
|
||||
let q = regex_to_bigram_query(pattern);
|
||||
|
||||
if let Some(bigrams) = expected {
|
||||
assert!(
|
||||
!q.is_any(),
|
||||
"#{i} {pattern:?}: expected bigrams but got Any"
|
||||
);
|
||||
|
||||
for &(pair, skip) in bigrams {
|
||||
let b = pair.as_bytes();
|
||||
debug_assert_eq!(b.len(), 2, "bigram must be 2 chars: {pair:?}");
|
||||
let found = if skip {
|
||||
has_skip1(&q, b[0], b[1])
|
||||
} else {
|
||||
has_consec(&q, b[0], b[1])
|
||||
};
|
||||
let kind = if skip { "skip-1" } else { "consec" };
|
||||
assert!(found, "#{i} {pattern:?}: missing {kind} bigram {pair:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
//! Constraint filtering engine for fff.
|
||||
//!
|
||||
//! This module provides the core constraint application logic that filters items
|
||||
//! based on parsed query constraints (extensions, path segments, globs, git status, etc.).
|
||||
//!
|
||||
//! The filtering is generic over the [`Constrainable`] trait, allowing reuse across
|
||||
//! different search modes (file picker, live grep, etc.).
|
||||
//! Constraint-based prefiltering for search queries.
|
||||
|
||||
use ahash::AHashSet;
|
||||
use fff_query_parser::{Constraint, GitStatusFilter};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
use crate::simd_path::ArenaPtr;
|
||||
|
||||
/// Case-insensitive ASCII substring search without allocation.
|
||||
/// `needle` must already be lowercase.
|
||||
#[inline]
|
||||
fn contains_ascii_ci(haystack: &str, needle: &str) -> bool {
|
||||
@@ -38,84 +32,118 @@ fn contains_ascii_ci(haystack: &str, needle: &str) -> bool {
|
||||
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;
|
||||
|
||||
/// Trait for items that can be filtered by constraints.
|
||||
/// Implement this for any searchable item type (files, grep results, etc.).
|
||||
pub trait Constrainable {
|
||||
/// The file's relative path (e.g. "src/main.rs")
|
||||
fn relative_path(&self) -> &str;
|
||||
|
||||
/// The file name component (e.g. "main.rs")
|
||||
fn file_name(&self) -> &str;
|
||||
|
||||
/// The git status of this item, if available
|
||||
pub(crate) trait Constrainable {
|
||||
fn write_file_name(&self, arena: ArenaPtr, out: &mut String);
|
||||
fn git_status(&self) -> Option<git2::Status>;
|
||||
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String);
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// Windows stores paths with `\\`; `/` comes from user queries.
|
||||
#[inline]
|
||||
fn is_path_sep(b: u8) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
b == b'/' || b == b'\\'
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
b == b'/'
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn path_slice_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b).all(|(x, y)| {
|
||||
if is_path_sep(*x) && is_path_sep(*y) {
|
||||
true
|
||||
} else {
|
||||
x.eq_ignore_ascii_case(y)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Path ends with suffix at a path-separator boundary (case-insensitive).
|
||||
#[inline]
|
||||
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
|
||||
if path.len() < suffix.len() {
|
||||
let path_bytes = path.as_bytes();
|
||||
let suffix_bytes = suffix.as_bytes();
|
||||
if path_bytes.len() < suffix_bytes.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let start = path.len() - suffix.len();
|
||||
if !path[start..].eq_ignore_ascii_case(suffix) {
|
||||
|
||||
// Multi-byte UTF-8 may put `start` inside a char.
|
||||
if !path.is_char_boundary(start) {
|
||||
return false;
|
||||
}
|
||||
// Exact match, or the character before is /
|
||||
start == 0 || path.as_bytes()[start - 1] == b'/'
|
||||
|
||||
if !path_slice_eq(&path_bytes[start..], suffix_bytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact or preceded by a separator. Scan backward past any multi-byte
|
||||
// continuation bytes to find the preceding ASCII byte.
|
||||
if start == 0 {
|
||||
return true;
|
||||
}
|
||||
let mut i = start;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
if path_bytes[i] < 128 {
|
||||
return is_path_sep(path_bytes[i]);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if file extension matches (without allocation)
|
||||
#[inline]
|
||||
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
|
||||
if file_name.len() <= ext.len() + 1 {
|
||||
let name_bytes = file_name.as_bytes();
|
||||
let ext_bytes = ext.as_bytes();
|
||||
if name_bytes.len() <= ext_bytes.len() + 1 {
|
||||
return false;
|
||||
}
|
||||
let start = file_name.len() - ext.len() - 1;
|
||||
file_name.as_bytes().get(start) == Some(&b'.')
|
||||
&& file_name[start + 1..].eq_ignore_ascii_case(ext)
|
||||
let start = name_bytes.len() - ext_bytes.len() - 1;
|
||||
if start > 0 && !file_name.is_char_boundary(start) {
|
||||
return false;
|
||||
}
|
||||
name_bytes.get(start) == Some(&b'.') && name_bytes[start + 1..].eq_ignore_ascii_case(ext_bytes)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Matches multi-segment queries like `libswscale/aarch64`.
|
||||
#[inline]
|
||||
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
|
||||
let path_bytes = path.as_bytes();
|
||||
let segment_len = segment.len();
|
||||
let segment_bytes = segment.as_bytes();
|
||||
let segment_len = segment_bytes.len();
|
||||
|
||||
// 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)
|
||||
if path_bytes.len() > segment_len
|
||||
&& is_path_sep(path_bytes[segment_len])
|
||||
&& path.is_char_boundary(segment_len)
|
||||
&& path_slice_eq(&path_bytes[..segment_len], segment_bytes)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check /segment/ anywhere using byte scanning
|
||||
if path.len() < segment_len + 2 {
|
||||
if path_bytes.len() < segment_len + 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
for i in 0..path.len().saturating_sub(segment_len + 1) {
|
||||
if path_bytes[i] == b'/' {
|
||||
for i in 0..path_bytes.len().saturating_sub(segment_len + 1) {
|
||||
if is_path_sep(path_bytes[i]) {
|
||||
let start = i + 1;
|
||||
let end = start + segment_len;
|
||||
if end < path.len()
|
||||
&& path_bytes[end] == b'/'
|
||||
&& path[start..end].eq_ignore_ascii_case(segment)
|
||||
if end < path_bytes.len()
|
||||
&& is_path_sep(path_bytes[end])
|
||||
&& path.is_char_boundary(start)
|
||||
&& path.is_char_boundary(end)
|
||||
&& path_slice_eq(&path_bytes[start..end], segment_bytes)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -124,8 +152,8 @@ pub fn path_contains_segment(path: &str, segment: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if an item at given index matches a constraint (single-pass friendly, allocation-free)
|
||||
#[inline]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
item: &T,
|
||||
item_index: usize,
|
||||
@@ -133,22 +161,35 @@ fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
glob_results: &[(bool, AHashSet<usize>)],
|
||||
glob_idx: &mut usize,
|
||||
negate: bool,
|
||||
arena: ArenaPtr,
|
||||
fname_buf: &mut String,
|
||||
path_buf: &mut String,
|
||||
) -> bool {
|
||||
let matches = match constraint {
|
||||
Constraint::Extension(ext) => file_has_extension(item.file_name(), ext),
|
||||
Constraint::Extension(ext) => {
|
||||
item.write_file_name(arena, fname_buf);
|
||||
file_has_extension(fname_buf, ext)
|
||||
}
|
||||
Constraint::Glob(_) => {
|
||||
let result = glob_results
|
||||
.get(*glob_idx)
|
||||
.map(|(is_neg, set)| {
|
||||
let matched = set.contains(&item_index);
|
||||
|
||||
if *is_neg { !matched } else { matched }
|
||||
})
|
||||
.unwrap_or(true);
|
||||
*glob_idx += 1;
|
||||
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::PathSegment(segment) => {
|
||||
item.write_relative_path(arena, path_buf);
|
||||
path_contains_segment(path_buf, segment)
|
||||
}
|
||||
Constraint::FilePath(suffix) => {
|
||||
item.write_relative_path(arena, path_buf);
|
||||
path_ends_with_suffix(path_buf, 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),
|
||||
@@ -171,11 +212,17 @@ fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
glob_results,
|
||||
glob_idx,
|
||||
!negate,
|
||||
arena,
|
||||
fname_buf,
|
||||
path_buf,
|
||||
);
|
||||
}
|
||||
|
||||
// only works with negation
|
||||
Constraint::Text(text) => contains_ascii_ci(item.relative_path(), text),
|
||||
Constraint::Text(text) => {
|
||||
item.write_relative_path(arena, path_buf);
|
||||
contains_ascii_ci(path_buf, text)
|
||||
}
|
||||
|
||||
// Parts and Exclude are handled at a higher level
|
||||
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
|
||||
@@ -184,15 +231,12 @@ fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
if negate { !matches } else { matches }
|
||||
}
|
||||
|
||||
/// Apply constraint-based prefiltering in a single pass over all items.
|
||||
/// Returns `None` if no constraints are present, `Some(filtered)` otherwise.
|
||||
/// Multiple extension constraints (*.rs *.ts) are combined with OR logic.
|
||||
/// All other constraints are combined with AND logic.
|
||||
///
|
||||
/// Uses parallel iteration via rayon when the item count exceeds [`PAR_THRESHOLD`].
|
||||
pub fn apply_constraints<'a, T: Constrainable + Sync>(
|
||||
/// Extension constraints use OR logic; all others use AND.
|
||||
pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
|
||||
items: &'a [T],
|
||||
constraints: &[Constraint<'_>],
|
||||
arena: ArenaPtr,
|
||||
) -> Option<Vec<&'a T>> {
|
||||
if constraints.is_empty() {
|
||||
return None;
|
||||
@@ -215,47 +259,107 @@ pub fn apply_constraints<'a, T: Constrainable + Sync>(
|
||||
.any(|c| matches!(c, Constraint::Glob(_) | Constraint::Not(_)));
|
||||
|
||||
let glob_results = if has_globs {
|
||||
let paths: Vec<&str> = items.iter().map(|f| f.relative_path()).collect();
|
||||
precompute_glob_matches(&other_constraints, &paths)
|
||||
// Build a single contiguous buffer of all relative paths + offset table.
|
||||
// One allocation for the buffer, one for offsets — NOT one String per file.
|
||||
// On Windows we fold `\\` into `/` while copying so globset/zlob see a
|
||||
// canonical separator. The rewrite is in place on bytes we just wrote.
|
||||
let mut path_buf = Vec::<u8>::new();
|
||||
let mut offsets = Vec::<(usize, usize)>::with_capacity(items.len());
|
||||
let mut tmp = String::with_capacity(64);
|
||||
for item in items.iter() {
|
||||
let start = path_buf.len();
|
||||
item.write_relative_path(arena, &mut tmp);
|
||||
path_buf.extend_from_slice(tmp.as_bytes());
|
||||
#[cfg(windows)]
|
||||
for b in &mut path_buf[start..] {
|
||||
if *b == b'\\' {
|
||||
*b = b'/';
|
||||
}
|
||||
}
|
||||
offsets.push((start, path_buf.len() - start));
|
||||
}
|
||||
let path_refs: Vec<&str> = offsets
|
||||
.iter()
|
||||
.map(|&(off, len)| unsafe { std::str::from_utf8_unchecked(&path_buf[off..off + len]) })
|
||||
.collect();
|
||||
precompute_glob_matches(&other_constraints, &path_refs)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let matches_constraints = |i: usize, item: &T| -> bool {
|
||||
if !extensions.is_empty()
|
||||
&& !extensions
|
||||
.iter()
|
||||
.any(|ext| file_has_extension(item.file_name(), ext))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut glob_idx = 0;
|
||||
other_constraints.iter().all(|constraint| {
|
||||
item_matches_constraint_at_index(
|
||||
item,
|
||||
i,
|
||||
constraint,
|
||||
&glob_results,
|
||||
&mut glob_idx,
|
||||
false,
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
let filtered: Vec<&T> = if items.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
items
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, item)| matches_constraints(*i, item))
|
||||
.map(|(_, item)| item)
|
||||
.map_init(
|
||||
|| (String::with_capacity(64), String::with_capacity(64)),
|
||||
|(fname_buf, path_buf), (i, item)| {
|
||||
if !extensions.is_empty() {
|
||||
item.write_file_name(arena, fname_buf);
|
||||
if !extensions
|
||||
.iter()
|
||||
.any(|ext| file_has_extension(fname_buf, ext))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut glob_idx = 0;
|
||||
if other_constraints.iter().all(|constraint| {
|
||||
item_matches_constraint_at_index(
|
||||
item,
|
||||
i,
|
||||
constraint,
|
||||
&glob_results,
|
||||
&mut glob_idx,
|
||||
false,
|
||||
arena,
|
||||
fname_buf,
|
||||
path_buf,
|
||||
)
|
||||
}) {
|
||||
Some(item)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.flatten()
|
||||
.collect()
|
||||
} else {
|
||||
let mut fname_buf = String::with_capacity(64);
|
||||
let mut path_buf = String::with_capacity(64);
|
||||
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, item)| matches_constraints(*i, item))
|
||||
.filter(|&(i, item)| {
|
||||
if !extensions.is_empty() {
|
||||
item.write_file_name(arena, &mut fname_buf);
|
||||
if !extensions
|
||||
.iter()
|
||||
.any(|ext| file_has_extension(&fname_buf, ext))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let mut glob_idx = 0;
|
||||
other_constraints.iter().all(|constraint| {
|
||||
item_matches_constraint_at_index(
|
||||
item,
|
||||
i,
|
||||
constraint,
|
||||
&glob_results,
|
||||
&mut glob_idx,
|
||||
false,
|
||||
arena,
|
||||
&mut fname_buf,
|
||||
&mut path_buf,
|
||||
)
|
||||
})
|
||||
})
|
||||
.map(|(_, item)| item)
|
||||
.collect()
|
||||
};
|
||||
@@ -278,15 +382,19 @@ fn collect_glob_indices<'a>(
|
||||
constraint: &Constraint<'a>,
|
||||
paths: &[&str],
|
||||
results: &mut Vec<(bool, AHashSet<usize>)>,
|
||||
is_negated: bool,
|
||||
_is_negated: bool,
|
||||
) {
|
||||
match constraint {
|
||||
Constraint::Glob(pattern) => {
|
||||
let indices = match_glob_pattern(pattern, paths);
|
||||
results.push((is_negated, indices));
|
||||
// Negation is handled by the `negate` parameter in
|
||||
// `item_matches_constraint_at_index`, NOT here. Storing
|
||||
// `is_negated=true` caused a double-negation bug when the
|
||||
// Glob arm also applied `negate`.
|
||||
results.push((false, indices));
|
||||
}
|
||||
Constraint::Not(inner) => {
|
||||
collect_glob_indices(inner, paths, results, !is_negated);
|
||||
collect_glob_indices(inner, paths, results, true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -356,6 +464,28 @@ fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestItem {
|
||||
relative_path: &'static str,
|
||||
file_name: &'static str,
|
||||
}
|
||||
|
||||
impl Constrainable for TestItem {
|
||||
fn write_file_name(&self, _arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
out.push_str(self.file_name);
|
||||
}
|
||||
|
||||
fn write_relative_path(&self, _arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
out.push_str(self.relative_path);
|
||||
}
|
||||
|
||||
fn git_status(&self) -> Option<git2::Status> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_has_extension() {
|
||||
assert!(file_has_extension("file.rs", "rs"));
|
||||
@@ -426,6 +556,32 @@ mod tests {
|
||||
assert!(!path_contains_segment("src", "src")); // no trailing slash
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_path_contains_segment_accepts_backslash() {
|
||||
assert!(path_contains_segment("src\\lib.rs", "src"));
|
||||
assert!(path_contains_segment(
|
||||
"app\\modules\\src\\services\\x.lua",
|
||||
"src"
|
||||
));
|
||||
assert!(path_contains_segment("app\\SRC\\x.lua", "src"));
|
||||
|
||||
assert!(path_contains_segment(
|
||||
"foo\\libswscale\\aarch64\\input.S",
|
||||
"libswscale/aarch64"
|
||||
));
|
||||
assert!(path_contains_segment(
|
||||
"crates\\fff-core\\src\\grep.rs",
|
||||
"fff-core/src"
|
||||
));
|
||||
|
||||
assert!(!path_contains_segment("mysrc\\lib.rs", "src"));
|
||||
assert!(!path_contains_segment(
|
||||
"xlibswscale\\aarch64\\in.S",
|
||||
"libswscale/aarch64"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_ends_with_suffix() {
|
||||
// Exact match
|
||||
@@ -469,4 +625,156 @@ mod tests {
|
||||
assert!(path_ends_with_suffix("src/main.rs", "src/main.rs"));
|
||||
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_path_ends_with_suffix_accepts_backslash() {
|
||||
assert!(path_ends_with_suffix(
|
||||
"app\\modules\\src\\services\\handler.lua",
|
||||
"services/handler.lua"
|
||||
));
|
||||
assert!(path_ends_with_suffix(
|
||||
"foo\\libswscale\\input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
assert!(!path_ends_with_suffix(
|
||||
"xlibswscale\\input.c",
|
||||
"libswscale/input.c"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_ends_with_suffix_does_not_panic_on_unicode_suffix() {
|
||||
assert!(!path_ends_with_suffix("유니코드_파일_테스트.csv", "트.c"));
|
||||
assert!(path_ends_with_suffix(
|
||||
"data/유니코드_파일_테스트.csv",
|
||||
"유니코드_파일_테스트.csv"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_ends_with_suffix_unicode_apostrophe_mismatch() {
|
||||
assert!(!path_ends_with_suffix(
|
||||
"dir/\u{2019}bar/file.txt",
|
||||
"'bar/file.txt"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_ends_with_suffix_unicode_space_mismatch() {
|
||||
assert!(!path_ends_with_suffix(
|
||||
"dir/\u{202f}am/file.txt",
|
||||
" am/file.txt"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_contains_segment_does_not_panic_on_unicode_segment() {
|
||||
assert!(!path_contains_segment("문서/notes.txt", "문x"));
|
||||
assert!(path_contains_segment("프로젝트/문서/notes.txt", "문서"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_contains_segment_unicode_no_panic() {
|
||||
assert!(!path_contains_segment(
|
||||
"Library/Cloud/Project\u{2019}s Folder/books.ttl",
|
||||
"Project's Folder"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_has_extension_unicode_no_panic() {
|
||||
assert!(!file_has_extension("cat\u{00e9}.rs", "s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_has_extension_unicode_filename() {
|
||||
assert!(file_has_extension("운영-가이드.md", "md"));
|
||||
assert!(file_has_extension("테스트.csv", "csv"));
|
||||
assert!(!file_has_extension("테스트.csv", "md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_constraints_file_path_with_unicode_suffix() {
|
||||
let arena_ptr = ArenaPtr(std::ptr::null());
|
||||
|
||||
let item = TestItem {
|
||||
relative_path: "data/유니코드_파일_테스트.csv",
|
||||
file_name: "유니코드_파일_테스트.csv",
|
||||
};
|
||||
|
||||
let exact = [Constraint::FilePath("유니코드_파일_테스트.csv")];
|
||||
let mismatch = [Constraint::FilePath("트.c")];
|
||||
|
||||
let exact_items = [item.clone()];
|
||||
let exact_matches =
|
||||
apply_constraints(&exact_items, &exact, arena_ptr).expect("constraints applied");
|
||||
assert_eq!(exact_matches.len(), 1);
|
||||
|
||||
let mismatch_items = [item];
|
||||
let mismatch_matches =
|
||||
apply_constraints(&mismatch_items, &mismatch, arena_ptr).expect("constraints applied");
|
||||
assert!(mismatch_matches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_path_no_panic_real_korean_cases() {
|
||||
// Real Korean paths that caused panics
|
||||
let path1 = "Downloads/(커리큘럼) hermes agent_정승현님 - 1차 커리큘럼 (강사님 작성).csv";
|
||||
let path2 = "hermes-agent-lecture-materials/세부_커리큘럼_최종.csv";
|
||||
let path3 = "projects/fastcampus-hermes-agent-curriculum/chapters/part-02-Hermes-설치-및-기본-사용/section-02-doctor로-설치-상태-검증/research/03-fix가-자동-수정하는-것과-못하는-것.md";
|
||||
|
||||
// These must not panic regardless of segment/suffix used
|
||||
assert!(!path_contains_segment(path1, "작성"));
|
||||
assert!(!path_ends_with_suffix(path1, "작성.csv"));
|
||||
assert!(!path_contains_segment(path2, "최종"));
|
||||
assert!(!path_ends_with_suffix(path2, "최종.csv"));
|
||||
assert!(!path_contains_segment(path3, "수정"));
|
||||
assert!(!path_ends_with_suffix(path3, "것.md"));
|
||||
|
||||
// Positive cases should still work
|
||||
assert!(path_contains_segment(
|
||||
path2,
|
||||
"hermes-agent-lecture-materials"
|
||||
));
|
||||
assert!(path_ends_with_suffix(
|
||||
path1,
|
||||
"(커리큘럼) hermes agent_정승현님 - 1차 커리큘럼 (강사님 작성).csv"
|
||||
));
|
||||
assert!(path_ends_with_suffix(path2, "세부_커리큘럼_최종.csv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negated_glob_excludes_matching_files() {
|
||||
let arena_ptr = ArenaPtr(std::ptr::null());
|
||||
|
||||
let items = vec![
|
||||
TestItem {
|
||||
relative_path: "src/main.rs",
|
||||
file_name: "main.rs",
|
||||
},
|
||||
TestItem {
|
||||
relative_path: "src/lib.ts",
|
||||
file_name: "lib.ts",
|
||||
},
|
||||
TestItem {
|
||||
relative_path: "include/fff.h",
|
||||
file_name: "fff.h",
|
||||
},
|
||||
];
|
||||
|
||||
// Not(Glob("**/*.rs")) should exclude .rs files
|
||||
let constraints = vec![Constraint::Not(Box::new(Constraint::Glob("**/*.rs")))];
|
||||
let result = apply_constraints(&items, &constraints, arena_ptr).unwrap();
|
||||
let paths: Vec<&str> = result.iter().map(|i| i.relative_path).collect();
|
||||
assert!(
|
||||
!paths.contains(&"src/main.rs"),
|
||||
"rs file should be excluded"
|
||||
);
|
||||
assert!(paths.contains(&"src/lib.ts"), "ts file should be included");
|
||||
assert!(
|
||||
paths.contains(&"include/fff.h"),
|
||||
"h file should be included"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use super::db_healthcheck::DbHealthChecker;
|
||||
use super::lmdb::{LmdbStore, is_map_full};
|
||||
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 heed::types::{Bytes, SerdeBincode};
|
||||
use heed::{Database, Env};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -16,6 +14,7 @@ use std::{collections::VecDeque, path::Path};
|
||||
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
|
||||
const MAX_TIMESTAMPS_PER_FILE: usize = 128;
|
||||
|
||||
// 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
|
||||
@@ -57,49 +56,38 @@ impl DbHealthChecker for FrecencyTracker {
|
||||
}
|
||||
}
|
||||
|
||||
impl LmdbStore for FrecencyTracker {
|
||||
const MAX_DBS: u32 = 0;
|
||||
// 10 MiB hard ceiling. Owner's db after years of use is ~560 KiB, so this
|
||||
// leaves ~18× headroom while capping runaway growth (see GH issue #437).
|
||||
const MAP_SIZE: usize = 10 * 1024 * 1024;
|
||||
// Nuke the db when it exceeds 8 MiB on disk — leaves a small margin under
|
||||
// MAP_SIZE so we don't hit MDB_MAP_FULL before the open-time erase fires.
|
||||
const SIZE_CAP_BYTES: u64 = 8 * 1024 * 1024;
|
||||
}
|
||||
|
||||
impl FrecencyTracker {
|
||||
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self> {
|
||||
/// Returns the on-disk path of the LMDB environment directory.
|
||||
pub fn db_path(&self) -> &Path {
|
||||
self.env.path()
|
||||
}
|
||||
|
||||
pub fn open(db_path: impl AsRef<Path>) -> Result<Self> {
|
||||
let db_path = db_path.as_ref();
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = Self::open_env(db_path)?;
|
||||
|
||||
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);
|
||||
}
|
||||
opts.open(db_path).map_err(Error::EnvOpen)?
|
||||
};
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
let db = Self::open_database_safe(&env, None)?;
|
||||
|
||||
// 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)?;
|
||||
Ok(FrecencyTracker { db, env })
|
||||
}
|
||||
|
||||
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,
|
||||
env: env.clone(),
|
||||
})
|
||||
#[deprecated(
|
||||
since = "0.7.0",
|
||||
note = "LMDB unsafe no-lock mode is no longer supported; use `FrecencyTracker::open` instead. \
|
||||
The `_use_unsafe_no_lock` argument is ignored."
|
||||
)]
|
||||
pub fn new(db_path: impl AsRef<Path>, _use_unsafe_no_lock: bool) -> Result<Self> {
|
||||
Self::open(db_path)
|
||||
}
|
||||
|
||||
/// Spawns a background thread to purge stale frecency entries and compact the database.
|
||||
@@ -111,26 +99,21 @@ impl FrecencyTracker {
|
||||
/// 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();
|
||||
/// let _ = FrecencyTracker::spawn_gc(shared_frecency, "/path/to/frecency_db".into()).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))?)
|
||||
.spawn(move || Self::run_frecency_gc(shared, db_path))?)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(shared), fields(db_path = %db_path))]
|
||||
fn run_frecency_gc(shared: SharedFrecency, db_path: String, use_unsafe_no_lock: bool) {
|
||||
fn run_frecency_gc(shared: SharedFrecency, db_path: String) {
|
||||
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,
|
||||
@@ -142,6 +125,14 @@ impl FrecencyTracker {
|
||||
let Some(ref tracker) = *guard else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Clear stale readers here (on a background thread) rather than in
|
||||
// open_env — clear_stale_readers needs the writer mutex which can
|
||||
// block indefinitely on a stuck lock if called on the main thread.
|
||||
if let Err(e) = tracker.env.clear_stale_readers() {
|
||||
tracing::debug!("clear_stale_readers failed: {e}");
|
||||
}
|
||||
|
||||
match tracker.purge_stale_entries() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
@@ -155,97 +146,14 @@ impl FrecencyTracker {
|
||||
tracing::info!(deleted, pruned, elapsed = ?start.elapsed(), "Frecency GC purged entries");
|
||||
}
|
||||
|
||||
// Compact if we purged entries OR the file has significant freelist bloat
|
||||
let data_path = PathBuf::from(&db_path).join("data.mdb");
|
||||
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);
|
||||
}
|
||||
if file_size > <Self as LmdbStore>::SIZE_CAP_BYTES {
|
||||
tracing::warn!(
|
||||
size = file_size,
|
||||
cap = <Self as LmdbStore>::SIZE_CAP_BYTES,
|
||||
"Frecency DB exceeds size cap — will be erased on next open"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,10 +213,13 @@ impl FrecencyTracker {
|
||||
}
|
||||
|
||||
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)?;
|
||||
self.db.get(&rtxn, &key_hash).map_err(Error::DbRead)
|
||||
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let result = self.db.get(&rtxn, &key_hash).map_err(Error::DbRead)?;
|
||||
rtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn get_now(&self) -> u64 {
|
||||
@@ -335,15 +246,16 @@ impl FrecencyTracker {
|
||||
}
|
||||
|
||||
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)?;
|
||||
let mut accesses = self.get_accesses(path)?.unwrap_or_default();
|
||||
|
||||
let now = self.get_now();
|
||||
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
|
||||
|
||||
// Drop stale timestamps from the front while also enforcing the
|
||||
// per-file cap. Reserves one slot for the `push_back` below.
|
||||
while let Some(&front_time) = accesses.front() {
|
||||
if front_time < cutoff_time {
|
||||
if front_time < cutoff_time || accesses.len() >= MAX_TIMESTAMPS_PER_FILE {
|
||||
accesses.pop_front();
|
||||
} else {
|
||||
break;
|
||||
@@ -353,13 +265,29 @@ impl FrecencyTracker {
|
||||
accesses.push_back(now);
|
||||
tracing::debug!(?path, accesses = accesses.len(), "Tracking access");
|
||||
|
||||
self.db
|
||||
.put(&mut wtxn, &key_hash, &accesses)
|
||||
.map_err(Error::DbWrite)?;
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
if let Err(e) = self.db.put(&mut wtxn, &key_hash, &accesses) {
|
||||
if is_map_full(&e) {
|
||||
tracing::error!(
|
||||
?path,
|
||||
"Frecency DB hit MDB_MAP_FULL; dropping write — db will be \
|
||||
erased on next open via LmdbStore::erase_if_oversized"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::DbWrite(e));
|
||||
}
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
Ok(())
|
||||
wtxn.commit()
|
||||
.inspect_err(|e| {
|
||||
if is_map_full(e) {
|
||||
tracing::error!(
|
||||
?path,
|
||||
"Frecency DB hit MDB_MAP_FULL on commit; dropping write"
|
||||
);
|
||||
}
|
||||
})
|
||||
.map_err(Error::DbCommit)
|
||||
}
|
||||
|
||||
pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
|
||||
@@ -528,7 +456,7 @@ mod tests {
|
||||
fn test_modification_score_interpolation() {
|
||||
let temp_dir = std::env::temp_dir().join("fff_test_interpolation");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
let tracker = FrecencyTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
let tracker = FrecencyTracker::open(temp_dir.to_str().unwrap()).unwrap();
|
||||
|
||||
let current_time = tracker.get_now();
|
||||
let git_status = Some(git2::Status::WT_MODIFIED);
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub(crate) fn is_map_full(err: &heed::Error) -> bool {
|
||||
matches!(err, heed::Error::Mdb(heed::MdbError::MapFull))
|
||||
}
|
||||
|
||||
// Concurrent `mdb_env_open` calls on the same path can race on macOS
|
||||
// this is for some reason fixabtly by simple retry of the open
|
||||
fn is_transient_env_open_error(err: &heed::Error) -> bool {
|
||||
match err {
|
||||
heed::Error::Io(io) => matches!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::NotFound
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait LmdbStore {
|
||||
/// LMDB map size in bytes. Must be a multiple of the OS page size.
|
||||
const MAP_SIZE: usize;
|
||||
/// Number of named sub-databases. `0` for single-db envs.
|
||||
const MAX_DBS: u32;
|
||||
/// Hard cap on `data.mdb` size.
|
||||
const SIZE_CAP_BYTES: u64;
|
||||
|
||||
#[tracing::instrument]
|
||||
fn open_env(db_path: &Path) -> Result<Env> {
|
||||
Self::erase_if_oversized(db_path);
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
let result = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.map_size(Self::MAP_SIZE);
|
||||
if Self::MAX_DBS > 0 {
|
||||
opts.max_dbs(Self::MAX_DBS);
|
||||
}
|
||||
opts.open(db_path)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(env) => return Ok(env),
|
||||
Err(e) if is_transient_env_open_error(&e) && attempt + 1 < MAX_ATTEMPTS => {
|
||||
attempt += 1;
|
||||
tracing::debug!(
|
||||
path = %db_path.display(),
|
||||
attempt,
|
||||
error = ?e,
|
||||
"transient LMDB env open error, retrying"
|
||||
);
|
||||
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => return Err(Error::EnvOpen(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open or create a database without blocking on the LMDB writer mutex
|
||||
/// when the database already exists.
|
||||
fn open_database_safe<KC, DC>(env: &Env, name: Option<&str>) -> Result<Database<KC, DC>>
|
||||
where
|
||||
KC: 'static,
|
||||
DC: 'static,
|
||||
{
|
||||
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let maybe_db: Option<Database<KC, DC>> =
|
||||
env.open_database(&rtxn, name).map_err(Error::DbOpen)?;
|
||||
|
||||
// do not drop the DB here
|
||||
rtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
match maybe_db {
|
||||
Some(db) => Ok(db),
|
||||
None => {
|
||||
// First time: create the database (requires write lock).
|
||||
// unfortunately this CAN be deadlocking and this is what we see happens
|
||||
// if the other part of the code is segfaulting, so the only rule to prevent this
|
||||
// write the good code mf, okay?
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, name)
|
||||
.map_err(Error::DbCreate)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
Ok(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn erase_if_oversized(db_path: &Path) {
|
||||
let data = db_path.join("data.mdb");
|
||||
let Ok(meta) = fs::metadata(&data) else {
|
||||
return;
|
||||
};
|
||||
if meta.len() <= Self::SIZE_CAP_BYTES {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::error!(
|
||||
path = %db_path.display(),
|
||||
size = meta.len(),
|
||||
cap = Self::SIZE_CAP_BYTES,
|
||||
"LMDB db exceeds size cap, erasing"
|
||||
);
|
||||
let _ = fs::remove_file(&data);
|
||||
let _ = fs::remove_file(db_path.join("lock.mdb"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod db_healthcheck;
|
||||
pub mod frecency;
|
||||
pub(crate) mod lmdb;
|
||||
pub mod query_tracker;
|
||||
@@ -1,11 +1,10 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use super::db_healthcheck::DbHealthChecker;
|
||||
use super::lmdb::{LmdbStore, is_map_full};
|
||||
use crate::error::Error;
|
||||
use heed::types::Bytes;
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{EnvFlags, types::SerdeBincode};
|
||||
use heed::types::{Bytes, SerdeBincode};
|
||||
use heed::{Database, Env};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -60,38 +59,28 @@ impl DbHealthChecker for QueryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
impl LmdbStore for QueryTracker {
|
||||
// 10 MiB hard ceiling. Same reasoning as FrecencyTracker (GH issue #437).
|
||||
const MAP_SIZE: usize = 10 * 1024 * 1024;
|
||||
const MAX_DBS: u32 = 16;
|
||||
// Nuke at 4 MiB — query history is bounded per-project but query→file
|
||||
// associations grow unbounded over typing time.
|
||||
const SIZE_CAP_BYTES: u64 = 4 * 1024 * 1024;
|
||||
}
|
||||
|
||||
impl QueryTracker {
|
||||
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
/// Returns the on-disk path of the LMDB environment directory.
|
||||
pub fn db_path(&self) -> &Path {
|
||||
self.env.path()
|
||||
}
|
||||
|
||||
pub fn open(db_path: impl AsRef<Path>) -> Result<Self, Error> {
|
||||
let db_path = db_path.as_ref();
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = Self::open_env(db_path)?;
|
||||
|
||||
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);
|
||||
}
|
||||
opts.open(db_path).map_err(Error::EnvOpen)?
|
||||
};
|
||||
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
// Create two named databases
|
||||
let query_file_db = env
|
||||
.create_database(&mut wtxn, Some("query_file_associations"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
let query_history_db = env
|
||||
.create_database(&mut wtxn, Some("query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
let grep_query_history_db = env
|
||||
.create_database(&mut wtxn, Some("grep_query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
let query_file_db = Self::open_database_safe(&env, Some("query_file_associations"))?;
|
||||
let query_history_db = Self::open_database_safe(&env, Some("query_history"))?;
|
||||
let grep_query_history_db = Self::open_database_safe(&env, Some("grep_query_history"))?;
|
||||
|
||||
Ok(QueryTracker {
|
||||
env,
|
||||
@@ -101,6 +90,15 @@ impl QueryTracker {
|
||||
})
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
since = "0.7.0",
|
||||
note = "LMDB unsafe no-lock mode is no longer supported; use `QueryTracker::open` instead. \
|
||||
The `_use_unsafe_no_lock` argument is ignored."
|
||||
)]
|
||||
pub fn new(db_path: impl AsRef<Path>, _use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
Self::open(db_path)
|
||||
}
|
||||
|
||||
fn get_now(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -225,15 +223,39 @@ impl QueryTracker {
|
||||
|
||||
entry.last_opened = now;
|
||||
|
||||
self.query_file_db
|
||||
.put(&mut wtxn, &query_key, &entry)
|
||||
.map_err(Error::DbWrite)?;
|
||||
if let Err(e) = self.query_file_db.put(&mut wtxn, &query_key, &entry) {
|
||||
if is_map_full(&e) {
|
||||
tracing::error!(
|
||||
?query,
|
||||
"Query tracker DB hit MDB_MAP_FULL; dropping write — db will \
|
||||
be erased on next open"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::DbWrite(e));
|
||||
}
|
||||
|
||||
// Update query history database
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)?;
|
||||
if let Err(e) =
|
||||
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)
|
||||
{
|
||||
if let Error::DbWrite(ref inner) = e
|
||||
&& is_map_full(inner)
|
||||
{
|
||||
tracing::error!(?query, "Query tracker DB map full while appending history");
|
||||
return Ok(());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
if let Err(e) = wtxn.commit() {
|
||||
if is_map_full(&e) {
|
||||
tracing::error!(?query, "Query tracker DB map full on commit");
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::DbCommit(e));
|
||||
}
|
||||
|
||||
tracing::debug!(?query, ?file_path, "Tracked query completion");
|
||||
Ok(())
|
||||
@@ -302,15 +324,29 @@ impl QueryTracker {
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
Self::append_to_history(
|
||||
if let Err(e) = Self::append_to_history(
|
||||
&self.grep_query_history_db,
|
||||
&mut wtxn,
|
||||
&project_key,
|
||||
query,
|
||||
now,
|
||||
)?;
|
||||
) {
|
||||
if let Error::DbWrite(ref inner) = e
|
||||
&& is_map_full(inner)
|
||||
{
|
||||
tracing::error!(?query, "Grep query history DB map full; dropping write");
|
||||
return Ok(());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
if let Err(e) = wtxn.commit() {
|
||||
if is_map_full(&e) {
|
||||
tracing::error!(?query, "Grep query history DB map full on commit");
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::DbCommit(e));
|
||||
}
|
||||
|
||||
tracing::debug!(?query, "Tracked grep query");
|
||||
Ok(())
|
||||
@@ -338,7 +374,7 @@ mod tests {
|
||||
let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
let mut tracker = QueryTracker::open(temp_dir.to_str().unwrap()).unwrap();
|
||||
|
||||
let project_path = PathBuf::from("/test/project");
|
||||
let file_path = PathBuf::from("/test/project/src/main.rs");
|
||||
@@ -8,7 +8,7 @@ pub enum Error {
|
||||
#[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"
|
||||
"Can not run certain FFF features in a file system root or home directories. Consider smaller per-project directories."
|
||||
)]
|
||||
FilesystemRoot(std::path::PathBuf),
|
||||
#[error("File picker not initialized")]
|
||||
@@ -21,6 +21,11 @@ pub enum Error {
|
||||
AcquirePathCacheLock,
|
||||
#[error("Failed to create directory: {0}")]
|
||||
CreateDir(#[from] std::io::Error),
|
||||
#[error("Failed to remove database directory {path}: {source}")]
|
||||
RemoveDbDir {
|
||||
path: std::path::PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("Failed to open frecency database env: {0}")]
|
||||
EnvOpen(#[source] heed::Error),
|
||||
#[error("Failed to create frecency database: {0}")]
|
||||
|
||||
+1463
-865
File diff suppressed because it is too large
Load Diff
+112
-26
@@ -1,4 +1,5 @@
|
||||
use crate::error::Result;
|
||||
use ahash::AHashMap;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
@@ -6,15 +7,21 @@ use std::{
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Represents a cache of a single git status query, if there is no
|
||||
/// status aka file is clear but it was specifically requested to updated
|
||||
/// the status is `None` otherwise contains only actual file statuses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStatusCache(Vec<(PathBuf, Status)>);
|
||||
pub(crate) fn default_status_options() -> StatusOptions {
|
||||
let mut opts = StatusOptions::new();
|
||||
opts.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true);
|
||||
opts
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct GitStatusCache(AHashMap<PathBuf, Status>);
|
||||
|
||||
impl IntoIterator for GitStatusCache {
|
||||
type Item = (PathBuf, Status);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
type IntoIter = <AHashMap<PathBuf, Status> as IntoIterator>::IntoIter;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
@@ -26,25 +33,27 @@ impl GitStatusCache {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
|
||||
self.0
|
||||
.binary_search_by(|(path, _)| path.as_path().cmp(full_path))
|
||||
.ok()
|
||||
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
|
||||
self.0.get(full_path).copied()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(repo, status_options))]
|
||||
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
|
||||
let statuses = repo.statuses(Some(status_options))?;
|
||||
let Some(repo_path) = repo.workdir() else {
|
||||
return Ok(Self(vec![])); // repo is bare
|
||||
return Ok(Self(AHashMap::new())); // repo is bare
|
||||
};
|
||||
|
||||
let mut entries = Vec::with_capacity(statuses.len());
|
||||
let repo_path = crate::path_utils::normalize(repo_path.to_path_buf());
|
||||
|
||||
let mut entries = AHashMap::with_capacity(statuses.len());
|
||||
for entry in &statuses {
|
||||
if let Some(entry_path) = entry.path() {
|
||||
let full_path = repo_path.join(entry_path);
|
||||
entries.push((full_path, entry.status()));
|
||||
// libgit2 returns entry paths with forward slashes on every platform
|
||||
// fff stores native paths - meaning we have forward slash issue on windows
|
||||
let full_path = crate::path_utils::normalize(repo_path.join(entry_path));
|
||||
entries.insert(full_path, entry.status());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,32 +85,29 @@ impl GitStatusCache {
|
||||
paths: &[TPath],
|
||||
) -> Result<Self> {
|
||||
if paths.is_empty() {
|
||||
return Ok(Self(vec![]));
|
||||
return Ok(Self(AHashMap::new()));
|
||||
}
|
||||
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return Ok(Self(vec![]));
|
||||
return Ok(Self(AHashMap::new()));
|
||||
};
|
||||
let workdir = crate::path_utils::normalize(workdir.to_path_buf());
|
||||
|
||||
// git pathspec is pretty slow and requires to walk the whole directory
|
||||
// so for a single file which is the most general use case we query directly the file
|
||||
if paths.len() == 1 {
|
||||
let full_path = paths[0].as_ref();
|
||||
let relative_path = full_path.strip_prefix(workdir)?;
|
||||
let relative_path = full_path.strip_prefix(&workdir)?;
|
||||
let status = repo.status_file(relative_path)?;
|
||||
|
||||
return Ok(Self(vec![(full_path.to_path_buf(), status)]));
|
||||
let mut map = AHashMap::with_capacity(1);
|
||||
map.insert(full_path.to_path_buf(), status);
|
||||
return Ok(Self(map));
|
||||
}
|
||||
|
||||
let mut status_options = StatusOptions::new();
|
||||
status_options
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
// when reading partial status it's important to include all files requested
|
||||
.include_unmodified(true);
|
||||
|
||||
let mut status_options = default_status_options();
|
||||
for path in paths {
|
||||
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
|
||||
status_options.pathspec(path.as_ref().strip_prefix(&workdir)?);
|
||||
}
|
||||
|
||||
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
|
||||
@@ -157,3 +163,83 @@ pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
format_git_status_opt(status).unwrap_or("unknown")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "t")
|
||||
.env("GIT_AUTHOR_EMAIL", "t@t")
|
||||
.env("GIT_COMMITTER_NAME", "t")
|
||||
.env("GIT_COMMITTER_EMAIL", "t@t")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
/// Regression: on case-insensitive filesystems libgit2 returns
|
||||
/// statuses in a case-insensitive order. Our previous sorted-`Vec` +
|
||||
/// `binary_search_by(Path::cmp)` lookup silently missed entries
|
||||
/// because `Path::cmp` is byte-wise.
|
||||
///
|
||||
/// This test uses deliberately mixed-case filenames so the two
|
||||
/// orderings disagree, then checks every lookup succeeds.
|
||||
#[test]
|
||||
fn lookup_is_case_exact_regardless_of_libgit2_sort_order() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// `std::fs::canonicalize` on Windows adds a `\\?\` UNC prefix that
|
||||
// libgit2's workdir string lacks. Use dunce so both sides match.
|
||||
let base = crate::path_utils::canonicalize(tmp.path()).unwrap();
|
||||
|
||||
// Mixed-case names that sort differently under byte-wise vs
|
||||
// case-insensitive comparators.
|
||||
let names = [
|
||||
"README.md",
|
||||
"a_lower.rs",
|
||||
"Z_upper.rs",
|
||||
"mixed_Case.txt",
|
||||
"nested/Inner_File.rs",
|
||||
];
|
||||
for n in &names {
|
||||
let p = base.join(n);
|
||||
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
fs::write(&p, format!("// {n}\n")).unwrap();
|
||||
}
|
||||
|
||||
git(&base, &["init", "-b", "main"]);
|
||||
git(&base, &["add", "-A"]);
|
||||
git(&base, &["commit", "-m", "seed", "--no-gpg-sign"]);
|
||||
|
||||
// Modify every file so they all end up in the status output as
|
||||
// WT_MODIFIED — guarantees a non-trivial map we have to look up.
|
||||
for n in &names {
|
||||
let p = base.join(n);
|
||||
fs::write(&p, format!("// {n}\n// edit\n")).unwrap();
|
||||
}
|
||||
|
||||
let repo = Repository::open(&base).unwrap();
|
||||
let paths: Vec<PathBuf> = names.iter().map(|n| base.join(n)).collect();
|
||||
let cache = GitStatusCache::git_status_for_paths(&repo, &paths).unwrap();
|
||||
|
||||
for (n, abs) in names.iter().zip(paths.iter()) {
|
||||
let status = cache.lookup_status(abs);
|
||||
assert!(
|
||||
status.is_some(),
|
||||
"lookup for {n} returned None; cache holds {} entries",
|
||||
cache.statuses_len(),
|
||||
);
|
||||
assert!(
|
||||
status.unwrap().contains(Status::WT_MODIFIED),
|
||||
"expected WT_MODIFIED for {n}, got {:?}",
|
||||
status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+568
-138
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,16 @@ pub(crate) const NON_GIT_IGNORED_DIRS: &[&str] = &[
|
||||
];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] =
|
||||
&["Library/Application Support", "Library/Caches"];
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
|
||||
"Library/Application Support",
|
||||
"Library/Caches",
|
||||
// App-group sandbox storage — used by iMessage, Photos, Notes, Calendar,
|
||||
// Electron apps, etc. for SQLite-WAL, LevelDB, protobuf files. These are
|
||||
// almost entirely extension-less binary files (~80k on a typical $HOME)
|
||||
// that never need to appear in a fuzzy or grep search.
|
||||
"Library/Group Containers",
|
||||
"Library/Containers",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
|
||||
|
||||
+20
-20
@@ -20,7 +20,7 @@
|
||||
//!
|
||||
//! ## Shared State
|
||||
//!
|
||||
//! [`SharedPicker`], [`SharedFrecency`], and [`SharedQueryTracker`] are
|
||||
//! [`SharedFilePicker`], [`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()`.
|
||||
@@ -33,10 +33,10 @@
|
||||
//! use fff_search::query_tracker::QueryTracker;
|
||||
//! use fff_search::{
|
||||
//! FFFMode, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
|
||||
//! SharedFrecency, SharedPicker, SharedQueryTracker,
|
||||
//! SharedFrecency, SharedFilePicker, SharedQueryTracker,
|
||||
//! };
|
||||
//!
|
||||
//! let shared_picker = SharedPicker::default();
|
||||
//! let shared_picker = SharedFilePicker::default();
|
||||
//! let shared_frecency = SharedFrecency::default();
|
||||
//! let shared_query_tracker = SharedQueryTracker::default();
|
||||
//!
|
||||
@@ -44,10 +44,10 @@
|
||||
//! std::fs::create_dir_all(&tmp).unwrap();
|
||||
//!
|
||||
//! // 1. Optionally initialize frecency and query tracker databases
|
||||
//! let frecency = FrecencyTracker::new(tmp.join("frecency"), false)?;
|
||||
//! let frecency = FrecencyTracker::open(tmp.join("frecency"))?;
|
||||
//! shared_frecency.init(frecency)?;
|
||||
//!
|
||||
//! let query_tracker = QueryTracker::new(tmp.join("queries"), false)?;
|
||||
//! let query_tracker = QueryTracker::open(tmp.join("queries"))?;
|
||||
//! shared_query_tracker.init(query_tracker)?;
|
||||
//!
|
||||
//! // 2. Init the file picker (spawns background scan + watcher)
|
||||
@@ -73,8 +73,7 @@
|
||||
//! let parser = QueryParser::default();
|
||||
//! let query = parser.parse("lib.rs");
|
||||
//!
|
||||
//! let results = FilePicker::fuzzy_search(
|
||||
//! picker.get_files(),
|
||||
//! let results = picker.fuzzy_search(
|
||||
//! &query,
|
||||
//! qt_guard.as_ref(),
|
||||
//! FuzzySearchOptions {
|
||||
@@ -86,31 +85,36 @@
|
||||
//! );
|
||||
//!
|
||||
//! assert!(results.total_matched > 0);
|
||||
//! assert!(results.items.first().unwrap().as_path().ends_with("lib.rs"));
|
||||
//! assert!(results.items.first().unwrap().relative_path(picker).ends_with("lib.rs"));
|
||||
//!
|
||||
//! let _ = std::fs::remove_dir_all(&tmp);
|
||||
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
|
||||
mod background_watcher;
|
||||
mod bigram_filter;
|
||||
mod scan;
|
||||
// public only for benchmarks — the inverted index is still re-exported via
|
||||
// `pub use bigram_filter::*` below for external consumers.
|
||||
#[doc(hidden)]
|
||||
pub mod bigram_filter;
|
||||
pub mod bigram_query;
|
||||
mod constraints;
|
||||
mod db_healthcheck;
|
||||
mod error;
|
||||
mod score;
|
||||
mod sort_buffer;
|
||||
// this is pub only for benchmarks
|
||||
pub mod case_insensitive_memmem;
|
||||
|
||||
pub(crate) mod simd_path;
|
||||
|
||||
/// 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;
|
||||
/// Database-backed persistence: frecency, query history, LMDB plumbing.
|
||||
pub mod dbs;
|
||||
pub use dbs::frecency;
|
||||
|
||||
/// Git status caching and repository detection utilities.
|
||||
pub mod git;
|
||||
@@ -128,11 +132,7 @@ pub mod log;
|
||||
/// 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;
|
||||
pub use dbs::query_tracker;
|
||||
|
||||
/// Core data types shared across the crate.
|
||||
pub mod types;
|
||||
@@ -143,7 +143,7 @@ mod ignore;
|
||||
pub mod shared;
|
||||
|
||||
pub use bigram_filter::*;
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use dbs::db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use fff_query_parser::*;
|
||||
pub use file_picker::*;
|
||||
|
||||
+58
-72
@@ -1,10 +1,11 @@
|
||||
//! Shared logging utilities for FFF crates.
|
||||
//!
|
||||
//! Provides file-based tracing initialization and a panic hook that writes
|
||||
//! to both stderr and a fallback log file.
|
||||
//! 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;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
@@ -12,82 +13,79 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
static CRASH_HANDLERS_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.
|
||||
/// 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() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
CRASH_HANDLERS_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>() {
|
||||
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
} else if let Some(s) = panic_info.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()
|
||||
};
|
||||
let location = panic_info
|
||||
.location()
|
||||
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||
.unwrap_or_else(|| "unknown location".to_string());
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("=================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
}
|
||||
|
||||
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`.
|
||||
///
|
||||
/// Accepts "trace", "debug", "info", "warn", "error" (case-insensitive).
|
||||
/// Returns `tracing::Level::INFO` for unrecognised values.
|
||||
pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
|
||||
match level.as_ref().map(|s| s.trim().to_lowercase()).as_deref() {
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
@@ -100,29 +98,19 @@ pub fn parse_log_level(level: Option<&str>) -> tracing::Level {
|
||||
}
|
||||
|
||||
/// Initialize tracing with a single log file.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist, truncates the log file,
|
||||
/// and sets up a non-blocking file appender with structured formatting.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let _ = 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) // creates a new file on every setup
|
||||
.truncate(true) // truncates a file on restart (instead of appending)
|
||||
.open(log_path)?;
|
||||
|
||||
let level = parse_log_level(log_level);
|
||||
@@ -137,8 +125,6 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
|
||||
.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),
|
||||
)
|
||||
|
||||
@@ -10,6 +10,20 @@ pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
std::fs::canonicalize(path)
|
||||
}
|
||||
|
||||
/// Git requires a normalized forward-slashed paths on windows
|
||||
#[cfg(windows)]
|
||||
pub fn normalize(path: PathBuf) -> PathBuf {
|
||||
let as_str = path.to_string_lossy();
|
||||
let with_backslashes: String = as_str.replace('/', "\\");
|
||||
let buf = PathBuf::from(with_backslashes);
|
||||
dunce::canonicalize(&buf).unwrap_or(buf)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn normalize(path: PathBuf) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
return PathBuf::from(path);
|
||||
@@ -26,53 +40,58 @@ pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
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 {
|
||||
let Some(ref current_path) = current_file else {
|
||||
return 0; // No penalty if no current file
|
||||
/// Calculate distance penalty based on directory proximity.
|
||||
/// Returns a negative penalty score based on how far the candidate is from the current file.
|
||||
///
|
||||
/// `candidate_dir` is the directory portion of the candidate path (e.g. `"src/components/"`).
|
||||
/// It may have a trailing `/` which is stripped internally.
|
||||
///
|
||||
/// Zero-allocation: walks both directory part iterators in lockstep.
|
||||
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_dir: &str) -> i32 {
|
||||
let Some(current_path) = current_file else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let current_dir = if let Some(parent) = std::path::Path::new(current_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let current_dir = Path::new(current_path).parent().unwrap_or(Path::new(""));
|
||||
let candidate = Path::new(candidate_dir);
|
||||
|
||||
let candidate_dir = if let Some(parent) = std::path::Path::new(candidate_path).parent() {
|
||||
parent.to_string_lossy().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if current_dir == candidate_dir {
|
||||
return 0; // Same directory, no penalty
|
||||
if current_dir == candidate {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let current_parts: Vec<&str> = current_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let candidate_parts: Vec<&str> = candidate_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let mut current_parts = current_dir.components();
|
||||
let mut candidate_parts = candidate.components();
|
||||
|
||||
let common_len = current_parts
|
||||
.iter()
|
||||
.zip(candidate_parts.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let mut common_len = 0usize;
|
||||
let mut current_total = 0usize;
|
||||
|
||||
let current_depth_from_common = current_parts.len() - common_len;
|
||||
|
||||
if current_depth_from_common == 0 {
|
||||
return 0; // Current file is at the common ancestor level
|
||||
loop {
|
||||
match (current_parts.next(), candidate_parts.next()) {
|
||||
(Some(a), Some(b)) => {
|
||||
current_total += 1;
|
||||
if a == b {
|
||||
common_len += 1;
|
||||
} else {
|
||||
current_total += current_parts.count();
|
||||
break;
|
||||
}
|
||||
}
|
||||
(Some(_), None) => {
|
||||
current_total += 1 + current_parts.count();
|
||||
break;
|
||||
}
|
||||
(None, _) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let penalty = -(current_depth_from_common as i32);
|
||||
let depth_from_common = current_total - common_len;
|
||||
if depth_from_common == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
penalty.max(-20)
|
||||
(-(depth_from_common as i32)).max(-20)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -82,16 +101,11 @@ mod tests {
|
||||
#[test]
|
||||
#[cfg(not(target_family = "windows"))]
|
||||
fn test_calculate_distance_penalty() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(None, "examples/user/test/mod.rs"),
|
||||
0
|
||||
);
|
||||
// candidate_dir is now just the directory portion (with or without trailing /)
|
||||
assert_eq!(calculate_distance_penalty(None, "examples/user/test/"), 0);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/main.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
),
|
||||
calculate_distance_penalty(Some("examples/user/test/main.rs"), "examples/user/test/"),
|
||||
0
|
||||
);
|
||||
//
|
||||
@@ -99,7 +113,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/subdir/file.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
"examples/user/test/"
|
||||
),
|
||||
-1
|
||||
);
|
||||
@@ -108,7 +122,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/dir1/file.rs"),
|
||||
"examples/user/test/dir2/mod.rs"
|
||||
"examples/user/test/dir2/"
|
||||
),
|
||||
-1
|
||||
);
|
||||
@@ -116,7 +130,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/lib/audio-announce.rs"),
|
||||
"examples/audio-announce/src/main.rs"
|
||||
"examples/audio-announce/src/"
|
||||
),
|
||||
-1
|
||||
);
|
||||
@@ -124,27 +138,27 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/audio-announce.rs"),
|
||||
"examples/pixel/src/main.rs"
|
||||
"examples/pixel/src/"
|
||||
),
|
||||
-2
|
||||
);
|
||||
|
||||
// Root level files
|
||||
assert_eq!(calculate_distance_penalty(Some("main.rs"), "lib.rs"), 0);
|
||||
// Root level files (empty dir)
|
||||
assert_eq!(calculate_distance_penalty(Some("main.rs"), ""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_family = "windows")]
|
||||
fn distance_penalty_works_on_windows() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(None, "examples\\user\\test\\mod.rs"),
|
||||
calculate_distance_penalty(None, "examples\\user\\test\\"),
|
||||
0
|
||||
);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\main.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
"examples\\user\\test\\"
|
||||
),
|
||||
0
|
||||
);
|
||||
@@ -153,7 +167,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\subdir\\file.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
"examples\\user\\test\\"
|
||||
),
|
||||
-1
|
||||
);
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
//! Unified scan-phase orchestrator.
|
||||
//!
|
||||
//! Every (re)index code path — initial scan, FFI-triggered rescan,
|
||||
//! watcher overflow rescan — goes through [`ScanJob::run`]. The
|
||||
//! orchestrator owns the *sequence* of a scan:
|
||||
//!
|
||||
//! 1. walk filesystem off-lock
|
||||
//! 2. swap `sync_data` under a brief write
|
||||
//! 3. apply git status + frecency off-lock
|
||||
//! 4. (optional, initial scan only) spawn the filesystem watcher
|
||||
//! 5. (optional) post-scan: auto-size cache budget, warmup, bigram
|
||||
//!
|
||||
//! The picker write lock is held only in step 2 and step 5's index
|
||||
//! install — both O(µs-ms), never seconds. Every other FFI caller on
|
||||
//! the nvim main thread keeps running.
|
||||
//!
|
||||
//! ## Entry points
|
||||
//!
|
||||
//! - [`ScanJob::spawn`] — fire-and-forget from `SharedPicker` state.
|
||||
//! Used by the watcher overflow path and by FFI (`scan_files`).
|
||||
//! - [`ScanJob::spawn_initial`] — same, but takes explicit config for
|
||||
//! the very first scan, before the `FilePicker` struct lives inside
|
||||
//! the shared handle.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::FileSync;
|
||||
use crate::background_watcher::BackgroundWatcher;
|
||||
use crate::bigram_filter::BigramOverlay;
|
||||
use crate::bigram_filter::build_bigram_index;
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::{self, FFFMode, warmup_mmaps};
|
||||
use crate::shared::{SharedFilePicker, SharedFrecency};
|
||||
use crate::types::ContentCacheBudget;
|
||||
|
||||
/// Shared atomic flags surfaced by the picker for the scan worker to
|
||||
/// signal its progress. Grouped so every callsite passes one value,
|
||||
/// not four.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ScanSignals {
|
||||
/// Set to `true` while any scan phase is running
|
||||
pub(crate) scanning: Arc<AtomicBool>,
|
||||
/// Set to `true` once the filesystem watcher has been installed
|
||||
pub(crate) watcher_ready: Arc<AtomicBool>,
|
||||
/// Indicates that that owning picker was requested to shut down
|
||||
pub(crate) cancelled: Arc<AtomicBool>,
|
||||
/// Soft lock indicating that the post scan non blocking work is active
|
||||
pub(crate) post_scan_busy: Arc<AtomicBool>,
|
||||
/// Used to resolve conflicts if multiple rescans were triggered in a queue
|
||||
pub(crate) rescan_pending: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Which optional phases a scan should run.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct ScanConfig {
|
||||
pub(crate) warmup: bool,
|
||||
pub(crate) content_indexing: bool,
|
||||
pub(crate) watch: bool,
|
||||
pub(crate) auto_cache_budget: bool,
|
||||
pub(crate) install_watcher: bool,
|
||||
}
|
||||
|
||||
/// A fully-configured scan job ready to run on a background thread.
|
||||
///
|
||||
/// Build with [`ScanJob::from_picker`] (reads all state from the
|
||||
/// current `FilePicker`) or [`ScanJob::initial`] (for the bootstrap
|
||||
/// scan, before the picker is published to `SharedPicker`).
|
||||
pub(crate) struct ScanJob {
|
||||
shared_picker: SharedFilePicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
base_path: PathBuf,
|
||||
mode: FFFMode,
|
||||
signals: ScanSignals,
|
||||
config: ScanConfig,
|
||||
/// Walker-maintained counter backing `get_scan_progress` on the UI
|
||||
/// side. Reset to 0 at scan start, incremented per-file by the
|
||||
/// walker. Shared `Arc` so the UI polls the same atomic.
|
||||
scanned_files_counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ScanJob {
|
||||
pub fn new(
|
||||
shared_picker: &SharedFilePicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
install_watcher: bool,
|
||||
) -> Result<Option<Self>, Error> {
|
||||
let guard = shared_picker.read()?;
|
||||
let picker = guard.as_ref().ok_or(Error::FilePickerMissing)?;
|
||||
|
||||
if picker.is_scan_active() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let signals = picker.scan_signals();
|
||||
if signals.post_scan_busy.load(Ordering::Acquire) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
shared_picker: shared_picker.clone(),
|
||||
shared_frecency: shared_frecency.clone(),
|
||||
base_path: picker.base_path().to_path_buf(),
|
||||
mode: picker.mode(),
|
||||
signals,
|
||||
scanned_files_counter: picker.scanned_files_counter(),
|
||||
config: ScanConfig {
|
||||
warmup: picker.has_mmap_cache(),
|
||||
content_indexing: picker.has_content_indexing(),
|
||||
watch: picker.has_watcher(),
|
||||
auto_cache_budget: !picker.has_explicit_cache_budget(),
|
||||
install_watcher,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Same as [`new`] but without reading from the picker — caller
|
||||
/// supplies the base path / mode / flags directly. Used by the
|
||||
/// bootstrap scan before the `FilePicker` is published to
|
||||
/// `SharedPicker`.
|
||||
pub fn new_initial(
|
||||
shared_picker: SharedFilePicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
base_path: PathBuf,
|
||||
mode: FFFMode,
|
||||
signals: ScanSignals,
|
||||
scanned_files_counter: Arc<AtomicUsize>,
|
||||
config: ScanConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
base_path,
|
||||
mode,
|
||||
signals,
|
||||
scanned_files_counter,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the job on a dedicated OS thread. Returns immediately.
|
||||
pub fn spawn(self) -> std::thread::JoinHandle<()> {
|
||||
self.signals.scanning.store(true, Ordering::Release);
|
||||
std::thread::Builder::new()
|
||||
.name("fff-scan".into())
|
||||
.spawn(move || self.run())
|
||||
.expect("failed to spawn fff-scan thread")
|
||||
}
|
||||
|
||||
fn run(self) {
|
||||
let Self {
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
base_path,
|
||||
mode,
|
||||
signals,
|
||||
scanned_files_counter,
|
||||
config,
|
||||
} = self;
|
||||
|
||||
let _scanning = ScanningGuard::new(&signals, config.install_watcher);
|
||||
|
||||
// Reset the UI-visible counter; the walker bumps it per file
|
||||
// and `get_scan_progress` reads it without locks.
|
||||
scanned_files_counter.store(0, Ordering::Relaxed);
|
||||
|
||||
// 1. Start git discovery and walk filesystem off-lock.
|
||||
let git_workdir = FileSync::discover_git_workdir(&base_path);
|
||||
let status_handle = git_workdir.clone().map(FileSync::spawn_git_status);
|
||||
let sync = match FileSync::walk_filesystem(
|
||||
&base_path,
|
||||
git_workdir,
|
||||
&scanned_files_counter,
|
||||
&shared_frecency,
|
||||
mode,
|
||||
) {
|
||||
Ok(sync) => sync,
|
||||
Err(e) => {
|
||||
error!(?e, "scan walk failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if signals.cancelled.load(Ordering::Acquire) {
|
||||
info!("walk completed but picker was replaced, discarding results");
|
||||
return;
|
||||
}
|
||||
|
||||
let git_workdir = sync.git_workdir.clone();
|
||||
|
||||
// 2. Brief write to install the freshly-walked file list.
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(picker) = guard.as_mut()
|
||||
{
|
||||
picker.commit_new_sync(sync);
|
||||
} else {
|
||||
error!("failed to install scan results into picker");
|
||||
return;
|
||||
}
|
||||
|
||||
// Files are now searchable — flip the scan signal *early* so
|
||||
// UI progress polls see the picker as "ready" while we run the
|
||||
// optional post-scan steps in the background.
|
||||
signals.scanning.store(false, Ordering::Relaxed);
|
||||
|
||||
// in case we do a rescan, we have to resubscribe a watcher to the new set of directories
|
||||
// all the already watched directories are not going to be resubscribed
|
||||
if !config.install_watcher && !signals.cancelled.load(Ordering::Acquire) {
|
||||
resubscribe_to_new_picker(&shared_picker);
|
||||
}
|
||||
|
||||
// 3. Apply git status + frecency off-lock.
|
||||
if !signals.cancelled.load(Ordering::Acquire)
|
||||
&& let Some(status_handle) = status_handle
|
||||
{
|
||||
file_picker::apply_git_status_and_frecency(
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
status_handle,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Install filesystem watcher (initial scan only).
|
||||
if config.install_watcher && config.watch && !signals.cancelled.load(Ordering::Acquire) {
|
||||
let shared_picker: &SharedFilePicker = &shared_picker;
|
||||
let shared_frecency: &SharedFrecency = &shared_frecency;
|
||||
let base_path: &std::path::Path = &base_path;
|
||||
|
||||
match BackgroundWatcher::new(
|
||||
base_path.to_path_buf(),
|
||||
git_workdir,
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
mode,
|
||||
) {
|
||||
Ok(watcher) => {
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(picker) = guard.as_mut()
|
||||
{
|
||||
picker.background_watcher = Some(watcher);
|
||||
}
|
||||
}
|
||||
Err(e) => error!(?e, "failed to initialize background watcher"),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Post-scan warmup + bigram build.
|
||||
if (config.warmup || config.content_indexing) && !signals.cancelled.load(Ordering::Acquire)
|
||||
{
|
||||
run_post_scan(&shared_picker, &base_path, &signals, &config);
|
||||
}
|
||||
|
||||
// 6. Drain any rescan that arrived while we were busy.
|
||||
//
|
||||
// `trigger_full_rescan_async` sets `rescan_pending` whenever a
|
||||
// caller asks for a rescan while `ScanJob::new` would have
|
||||
// returned `Ok(None)` (scan active *or* post-scan busy). We
|
||||
// consume the flag with `swap` so concurrent requests that land
|
||||
// between the check and the follow-up spawn are still captured
|
||||
// by the next invocation.
|
||||
if !signals.cancelled.load(Ordering::Acquire)
|
||||
&& signals.rescan_pending.swap(false, Ordering::AcqRel)
|
||||
{
|
||||
match Self::new(&shared_picker, &shared_frecency, false) {
|
||||
Ok(Some(follow_up)) => {
|
||||
info!("Rescheduling deferred rescan after current scan finished");
|
||||
follow_up.spawn();
|
||||
}
|
||||
Ok(None) => {
|
||||
// Another scan slipped in between our post-scan exit
|
||||
// and the `new()` call above. That scan will drain
|
||||
// the flag we just cleared — but we re-arm it so it
|
||||
// does.
|
||||
signals.rescan_pending.store(true, Ordering::Release);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(?e, "Failed to reschedule deferred rescan");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII helper that flips the `scanning` signal on construction and
|
||||
/// resets it on drop (so early-returns can't leave it stuck on `true`).
|
||||
/// Also drives the `watcher_ready` signal on the initial-scan path.
|
||||
struct ScanningGuard<'a> {
|
||||
signals: &'a ScanSignals,
|
||||
release_watcher_ready_on_drop: bool,
|
||||
}
|
||||
|
||||
impl<'a> ScanningGuard<'a> {
|
||||
fn new(signals: &'a ScanSignals, release_watcher_ready_on_drop: bool) -> Self {
|
||||
signals.scanning.store(true, Ordering::Relaxed);
|
||||
Self {
|
||||
signals,
|
||||
release_watcher_ready_on_drop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScanningGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.signals.scanning.store(false, Ordering::Relaxed);
|
||||
if self.release_watcher_ready_on_drop {
|
||||
self.signals.watcher_ready.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_post_scan(
|
||||
shared_picker: &SharedFilePicker,
|
||||
base_path: &std::path::Path,
|
||||
signals: &ScanSignals,
|
||||
config: &ScanConfig,
|
||||
) {
|
||||
let phase_start = std::time::Instant::now();
|
||||
|
||||
// Auto-scale the cache budget before we take the files snapshot —
|
||||
// warmup needs the final budget.
|
||||
if config.auto_cache_budget
|
||||
&& !signals.cancelled.load(Ordering::Acquire)
|
||||
&& let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(picker) = guard.as_mut()
|
||||
&& !picker.has_explicit_cache_budget()
|
||||
{
|
||||
let (files, _, _) = picker.sync_data_snapshot();
|
||||
picker.set_cache_budget(ContentCacheBudget::new_for_repo(files.len()));
|
||||
}
|
||||
|
||||
let Some((files, indexable_count, budget, arena, _busy_guard)) = shared_picker
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|p| snapshot_sync_data(p, signals)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if config.warmup && !signals.cancelled.load(Ordering::Acquire) {
|
||||
let t = std::time::Instant::now();
|
||||
warmup_mmaps(files, &budget, base_path, arena);
|
||||
info!(
|
||||
"Warmup completed in {:.2}s (cached {} files, {} bytes)",
|
||||
t.elapsed().as_secs_f64(),
|
||||
budget.cached_count.load(Ordering::Relaxed),
|
||||
budget.cached_bytes.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
|
||||
if config.content_indexing && !signals.cancelled.load(Ordering::Acquire) {
|
||||
let indexable_files = &files[..indexable_count.min(files.len())];
|
||||
let (index, content_binary) =
|
||||
build_bigram_index(indexable_files, &budget, base_path, arena);
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(picker) = guard.as_mut()
|
||||
{
|
||||
for &idx in &content_binary {
|
||||
if let Some(file) = picker.get_file_mut(idx) {
|
||||
file.set_binary(true);
|
||||
}
|
||||
}
|
||||
picker.set_bigram_index(index, BigramOverlay::new(indexable_count));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Post-scan phase total: {:.2}s (warmup={}, content_indexing={})",
|
||||
phase_start.elapsed().as_secs_f64(),
|
||||
config.warmup,
|
||||
config.content_indexing,
|
||||
);
|
||||
}
|
||||
|
||||
struct PostScanBusyGuard<'a>(&'a AtomicBool);
|
||||
impl Drop for PostScanBusyGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-registers all the directories at the watcher
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn resubscribe_to_new_picker(shared_picker: &SharedFilePicker) {
|
||||
let Ok(guard) = shared_picker.read() else {
|
||||
return;
|
||||
};
|
||||
let Some(picker) = guard.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(watcher) = picker.background_watcher.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Base path first — this is the watch that delivers `Create(Folder)`
|
||||
// events for brand-new top-level subdirs. On rescan paths this
|
||||
// watch is still alive (the BackgroundWatcher survives rescans), so
|
||||
// the call is idempotent. Including it explicitly protects against
|
||||
// any future refactor that could drop the initial base-path watch.
|
||||
watcher.request_watch_dir(picker.base_path().to_path_buf());
|
||||
|
||||
picker.for_each_dir(|dir: &std::path::Path| {
|
||||
watcher.request_watch_dir(dir.to_path_buf());
|
||||
std::ops::ControlFlow::Continue(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Take a `'static`-lifetime snapshot of `sync_data` pinned by a
|
||||
/// post-scan busy guard. Concurrent rescans short-circuit while the
|
||||
/// returned guard is alive, so the raw slice can't be freed from under
|
||||
/// the warmup + bigram build that consumes it.
|
||||
fn snapshot_sync_data<'a>(
|
||||
picker: &crate::file_picker::FilePicker,
|
||||
signals: &'a ScanSignals,
|
||||
) -> (
|
||||
&'static [crate::types::FileItem],
|
||||
usize,
|
||||
Arc<ContentCacheBudget>,
|
||||
crate::simd_path::ArenaPtr,
|
||||
PostScanBusyGuard<'a>,
|
||||
) {
|
||||
signals.post_scan_busy.store(true, Ordering::Release);
|
||||
let busy = PostScanBusyGuard(&signals.post_scan_busy);
|
||||
|
||||
let (files, indexable_count, arena) = picker.sync_data_snapshot();
|
||||
let ptr = files.as_ptr();
|
||||
let len = files.len();
|
||||
let static_files: &'static [crate::types::FileItem] =
|
||||
unsafe { std::slice::from_raw_parts(ptr, len) };
|
||||
(
|
||||
static_files,
|
||||
indexable_count,
|
||||
picker.cache_budget_arc(),
|
||||
arena,
|
||||
busy,
|
||||
)
|
||||
}
|
||||
+979
-222
File diff suppressed because it is too large
Load Diff
+245
-40
@@ -1,45 +1,125 @@
|
||||
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use std::time::Duration;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::scan::ScanJob;
|
||||
|
||||
/// Poll `.git/index.lock` until it disappears (git write completed), giving up
|
||||
/// after [`GIT_LOCK_MAX_WAIT`]. Used by [`SharedPicker::refresh_git_status`]
|
||||
/// to avoid reading a half-updated index when the watcher fires mid-`git add`.
|
||||
///
|
||||
/// The wait is bounded and cheap: the lock file is typically cleared within
|
||||
/// a few milliseconds of the git command exiting.
|
||||
fn wait_for_git_index_lock_release(git_root: &Path) {
|
||||
const GIT_LOCK_POLL: Duration = Duration::from_millis(10);
|
||||
const GIT_LOCK_MAX_WAIT: Duration = Duration::from_millis(500);
|
||||
|
||||
let lock = git_root.join(".git").join("index.lock");
|
||||
// Fast path: no lock present.
|
||||
if !lock.exists() {
|
||||
return;
|
||||
}
|
||||
let deadline = Instant::now() + GIT_LOCK_MAX_WAIT;
|
||||
while lock.exists() && Instant::now() < deadline {
|
||||
std::thread::sleep(GIT_LOCK_POLL);
|
||||
}
|
||||
if lock.exists() {
|
||||
tracing::warn!(
|
||||
"Proceeding with git status refresh despite lingering \
|
||||
.git/index.lock at {} — will retry once it clears",
|
||||
lock.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FilePicker`] instance.
|
||||
/// This accumulates only asynchronous non-blocking operations against the
|
||||
/// file picker: creating, triggering various rescans and so on.
|
||||
///
|
||||
/// 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.
|
||||
/// For blocking access use internal picker via `.read()` or `.write()`
|
||||
///
|
||||
/// `Clone` gives a new handle to the same picker (Arc clone).
|
||||
/// `Default` creates an empty handle suitable for `Lazy::new(SharedPicker::default)`.
|
||||
/// ```ignore
|
||||
/// let shared_picker = SharedFilePicker::default();
|
||||
///
|
||||
/// if let Some(picker) = shared_picker.read()?.as_ref() {
|
||||
/// let files = picker.fuzzy_search(&query, options);
|
||||
/// println!("Found {} files", files.len());
|
||||
/// } else {
|
||||
/// println!("Picker not initialized");
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SharedPicker(pub(crate) Arc<parking_lot::RwLock<Option<FilePicker>>>);
|
||||
pub struct SharedFilePicker(pub(crate) Arc<SharedPickerInner>);
|
||||
|
||||
impl std::fmt::Debug for SharedPicker {
|
||||
pub struct SharedPickerInner {
|
||||
picker: parking_lot::RwLock<Option<FilePicker>>,
|
||||
}
|
||||
|
||||
impl Default for SharedPickerInner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
picker: parking_lot::RwLock::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-owning handle to a [`SharedPicker`].
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WeakFilePicker(Weak<SharedPickerInner>);
|
||||
|
||||
impl WeakFilePicker {
|
||||
/// Try to promote the weak handle back to a strong [`SharedPicker`].
|
||||
///
|
||||
/// Returns `None` once every strong `SharedPicker` clone has been
|
||||
/// dropped. Callers should treat that as "the picker is being
|
||||
/// torn down" and exit their current iteration cleanly.
|
||||
pub(crate) fn upgrade(&self) -> Option<SharedFilePicker> {
|
||||
self.0.upgrade().map(SharedFilePicker)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedFilePicker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedPicker").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedPicker {
|
||||
impl SharedFilePicker {
|
||||
pub fn read(&self) -> Result<parking_lot::RwLockReadGuard<'_, Option<FilePicker>>, Error> {
|
||||
Ok(self.0.read())
|
||||
Ok(self.0.picker.read())
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, Option<FilePicker>>, Error> {
|
||||
Ok(self.0.write())
|
||||
Ok(self.0.picker.write())
|
||||
}
|
||||
|
||||
/// Produce a non-owning handle to the same inner picker.
|
||||
/// Use it if you don't need to block internal threads from dropping while owning this ref
|
||||
pub(crate) fn weaken(&self) -> WeakFilePicker {
|
||||
WeakFilePicker(Arc::downgrade(&self.0))
|
||||
}
|
||||
|
||||
/// Return `true` if this is an instance of the picker that requires a complicated post-scan
|
||||
/// indexing/cache warmup job. The indexing is not crazy but it takes time.
|
||||
pub fn need_complex_rebuild(&self) -> bool {
|
||||
let guard = self.0.picker.read();
|
||||
guard
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.has_mmap_cache() || p.has_content_indexing())
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let guard = self.0.picker.read();
|
||||
match &*guard {
|
||||
Some(picker) => picker.scan_signal(),
|
||||
Some(picker) => Arc::clone(&picker.signals.scanning),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
@@ -57,16 +137,16 @@ impl SharedPicker {
|
||||
/// 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();
|
||||
let watch_ready_signal = {
|
||||
let guard = self.0.picker.read();
|
||||
match &*guard {
|
||||
Some(picker) => picker.watcher_signal(),
|
||||
Some(picker) => Arc::clone(&picker.signals.watcher_ready),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while !signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
while !watch_ready_signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
@@ -75,9 +155,37 @@ impl SharedPicker {
|
||||
true
|
||||
}
|
||||
|
||||
/// Trigger a full filesystem rescan without blocking the caller.
|
||||
/// Performs a safe async rescan. Guarantees only single active rescan per picker.
|
||||
/// If many rescans requested the last one guaranteed to be finished.
|
||||
pub fn trigger_full_rescan_async(&self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
|
||||
match ScanJob::new(self, shared_frecency, /*install_watcher=*/ false)? {
|
||||
Some(job) => {
|
||||
job.spawn();
|
||||
}
|
||||
None => {
|
||||
// A scan is already in flight — mark a follow-up as
|
||||
// needed. The running scan's `run()` drains this flag
|
||||
// and reschedules itself.
|
||||
if let Ok(guard) = self.read()
|
||||
&& let Some(picker) = guard.as_ref()
|
||||
{
|
||||
picker
|
||||
.scan_signals()
|
||||
.rescan_pending
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
tracing::info!(
|
||||
"Full rescan requested while another scan is active — \
|
||||
deferred via rescan_pending flag"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 = {
|
||||
@@ -91,13 +199,21 @@ impl SharedPicker {
|
||||
picker.git_root()
|
||||
);
|
||||
|
||||
// Wait briefly for any in-progress git operation to release
|
||||
// its `.git/index.lock`. libgit2 reads `.git/index` directly
|
||||
// and does NOT coordinate with the filesystem lock; if a
|
||||
// writer is mid-atomic-rename (lock file exists, new index
|
||||
// not yet swapped in), we would observe stale status data.
|
||||
// This matters most for the background watcher, which
|
||||
// typically fires refresh in response to the very events
|
||||
// produced by that in-flight git write.
|
||||
if let Some(root) = picker.git_root() {
|
||||
wait_for_git_index_lock_release(root);
|
||||
}
|
||||
|
||||
GitStatusCache::read_git_status(
|
||||
picker.git_root(),
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true),
|
||||
&mut crate::git::default_status_options(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -117,8 +233,20 @@ impl SharedPicker {
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SharedFrecency(pub(crate) Arc<RwLock<Option<FrecencyTracker>>>);
|
||||
#[derive(Clone)]
|
||||
pub struct SharedFrecency {
|
||||
inner: Arc<RwLock<Option<FrecencyTracker>>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SharedFrecency {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedFrecency {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -127,34 +255,77 @@ impl std::fmt::Debug for SharedFrecency {
|
||||
}
|
||||
|
||||
impl SharedFrecency {
|
||||
/// Creates a disabled instance that silently ignores all writes.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
self.0.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
self.0.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
/// Initialize the frecency tracker, replacing any existing one.
|
||||
/// Initialize the frecency tracker. No-op if this is a disabled instance.
|
||||
pub fn init(&self, tracker: FrecencyTracker) -> Result<(), Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
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)
|
||||
pub fn spawn_gc(&self, db_path: String) -> crate::Result<std::thread::JoinHandle<()>> {
|
||||
FrecencyTracker::spawn_gc(self.clone(), db_path)
|
||||
}
|
||||
|
||||
/// Drop the in-memory tracker and delete the on-disk database directory.
|
||||
///
|
||||
/// Acquires the write lock, ensuring all readers (including any active mmap
|
||||
/// access) are finished before the LMDB environment is closed and the files
|
||||
/// are removed.
|
||||
///
|
||||
/// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
|
||||
/// tracker was initialized.
|
||||
pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
|
||||
let mut guard = self.write()?;
|
||||
let Some(tracker) = guard.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let db_path = tracker.db_path().to_path_buf();
|
||||
// Drop closes the LMDB env and unmaps the files
|
||||
drop(tracker);
|
||||
drop(guard);
|
||||
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
|
||||
path: db_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(Some(db_path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`QueryTracker`] instance.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SharedQueryTracker(pub(crate) Arc<RwLock<Option<QueryTracker>>>);
|
||||
#[derive(Clone)]
|
||||
pub struct SharedQueryTracker {
|
||||
inner: Arc<RwLock<Option<QueryTracker>>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SharedQueryTracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedQueryTracker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -163,18 +334,52 @@ impl std::fmt::Debug for SharedQueryTracker {
|
||||
}
|
||||
|
||||
impl SharedQueryTracker {
|
||||
/// Creates a disabled instance that silently ignores all writes.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<QueryTracker>>, Error> {
|
||||
self.0.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<QueryTracker>>, Error> {
|
||||
self.0.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
/// Initialize the query tracker, replacing any existing one.
|
||||
/// Initialize the query tracker. No-op if this is a disabled instance.
|
||||
pub fn init(&self, tracker: QueryTracker) -> Result<(), Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let mut guard = self.write()?;
|
||||
*guard = Some(tracker);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the in-memory tracker and delete the on-disk database directory.
|
||||
///
|
||||
/// Acquires the write lock, ensuring all readers (including any active mmap
|
||||
/// access) are finished before the LMDB environment is closed and the files
|
||||
/// are removed.
|
||||
///
|
||||
/// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
|
||||
/// tracker was initialized.
|
||||
pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
|
||||
let mut guard = self.write()?;
|
||||
let Some(tracker) = guard.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let db_path = tracker.db_path().to_path_buf();
|
||||
drop(tracker);
|
||||
drop(guard);
|
||||
std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
|
||||
path: db_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(Some(db_path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
use ahash::AHashMap;
|
||||
use smallvec::SmallVec;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// SIMD chunk size in bytes (matches NEON/SSE2 register width).
|
||||
/// This must stay in sync with neo_frizbee's internal chunk size.
|
||||
pub(crate) const SIMD_CHUNK_BYTES: usize = 16;
|
||||
|
||||
/// 4 chunks = 64 bytes inline, covers ~85% of paths without heap fallback.
|
||||
const INLINE_CHUNKS: usize = 4;
|
||||
|
||||
pub(crate) type ChunkIndices = SmallVec<[u32; INLINE_CHUNKS]>;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ArenaPtr(pub(crate) *const u8);
|
||||
|
||||
// SAFETY: The arena is a read-only immutable part of file sync
|
||||
unsafe impl Send for ArenaPtr {}
|
||||
unsafe impl Sync for ArenaPtr {}
|
||||
|
||||
impl ArenaPtr {
|
||||
#[inline]
|
||||
pub fn new(ptr: *const u8) -> Self {
|
||||
Self(ptr)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn null() -> Self {
|
||||
Self(std::ptr::null())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn as_ptr(self) -> *const u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ArenaPtr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "--arena-raw-pointer-0x({:?})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C, align(16))]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SimdChunk(pub(crate) [u8; SIMD_CHUNK_BYTES]);
|
||||
|
||||
impl Default for SimdChunk {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self([0u8; SIMD_CHUNK_BYTES])
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SimdChunk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Show the actual bytes, trimming trailing zeros for readability
|
||||
let end = self.0.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
|
||||
write!(f, "SimdChunk({:?})", &self.0[..end])
|
||||
}
|
||||
}
|
||||
|
||||
pub const PATH_BUF_SIZE: usize = 4096;
|
||||
|
||||
/// Indices into a shared `SimdChunk` arena representing a file path.
|
||||
///
|
||||
/// All read methods require an explicit `arena_base` pointer from the owning
|
||||
/// `ChunkedPathStore`. The struct itself contains no raw pointers to the arena
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ChunkedString {
|
||||
indices: ChunkIndices,
|
||||
pub byte_len: u16,
|
||||
/// Byte offset where the filename begins. 0 for root-level files.
|
||||
pub filename_offset: u16,
|
||||
}
|
||||
|
||||
impl ChunkedString {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
indices: SmallVec::new(),
|
||||
byte_len: 0,
|
||||
filename_offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn new(indices: ChunkIndices, byte_len: u16, filename_offset: u16) -> Self {
|
||||
Self {
|
||||
indices,
|
||||
byte_len,
|
||||
filename_offset,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn chunk_count(&self) -> usize {
|
||||
self.indices.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn resolve_ptrs<'a>(
|
||||
&self,
|
||||
arena: ArenaPtr,
|
||||
buf: &'a mut [*const u8; 32],
|
||||
) -> &'a [*const u8] {
|
||||
let count = self.indices.len();
|
||||
let base = arena.as_ptr();
|
||||
for (i, &idx) in self.indices.iter().enumerate() {
|
||||
buf[i] = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
}
|
||||
&buf[..count]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_slice_to_vec(
|
||||
indices: &[u32],
|
||||
base: *const u8,
|
||||
offset_in_chunk: usize,
|
||||
len: usize,
|
||||
vec: &mut Vec<u8>,
|
||||
) {
|
||||
let mut written = 0usize;
|
||||
for (i, &idx) in indices.iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let chunk_bytes = unsafe { core::slice::from_raw_parts(src, SIMD_CHUNK_BYTES) };
|
||||
let start = if i == 0 { offset_in_chunk } else { 0 };
|
||||
let end = SIMD_CHUNK_BYTES.min(start + (len - written));
|
||||
vec.extend_from_slice(&chunk_bytes[start..end]);
|
||||
written += end - start;
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the filename portion as a `Cow<str>`.
|
||||
///
|
||||
/// When the filename starts at a chunk boundary and fits in one chunk we
|
||||
/// borrow directly from the arena (zero-copy). Otherwise we allocate.
|
||||
/// Filenames are almost always <=16 bytes so the fast path dominates.
|
||||
#[inline]
|
||||
pub fn filename_cow<'a>(&self, arena: ArenaPtr) -> Cow<'a, str> {
|
||||
let fname_offset = self.filename_offset as usize;
|
||||
let fname_len = self.byte_len as usize - fname_offset;
|
||||
if fname_len == 0 {
|
||||
return Cow::Borrowed("");
|
||||
}
|
||||
|
||||
let base = arena.as_ptr();
|
||||
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
|
||||
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
|
||||
|
||||
if offset_in_chunk == 0 && fname_len <= SIMD_CHUNK_BYTES {
|
||||
let ptr = unsafe { base.add(self.indices[start_chunk] as usize * SIMD_CHUNK_BYTES) };
|
||||
let slice = unsafe { core::slice::from_raw_parts(ptr, fname_len) };
|
||||
return Cow::Borrowed(unsafe { core::str::from_utf8_unchecked(slice) });
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(fname_len);
|
||||
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
|
||||
Self::write_slice_to_vec(
|
||||
&self.indices[start_chunk..start_chunk + needed_chunks],
|
||||
base,
|
||||
offset_in_chunk,
|
||||
fname_len,
|
||||
unsafe { out.as_mut_vec() },
|
||||
);
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Truncates at `buf.len()` if exceeded -- use `[u8; PATH_BUF_SIZE]` to avoid.
|
||||
#[inline]
|
||||
pub fn read_to_buf<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
|
||||
let total = (self.byte_len as usize).min(buf.len());
|
||||
let usable_chunks = total.div_ceil(SIMD_CHUNK_BYTES);
|
||||
let chunks_to_copy = usable_chunks.min(self.indices.len());
|
||||
let base = arena.as_ptr();
|
||||
|
||||
for (i, &idx) in self.indices[..chunks_to_copy].iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let dst_offset = i * SIMD_CHUNK_BYTES;
|
||||
let take = SIMD_CHUNK_BYTES.min(total - dst_offset);
|
||||
|
||||
unsafe {
|
||||
core::ptr::copy_nonoverlapping(src, buf.as_mut_ptr().add(dst_offset), take);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe { core::str::from_utf8_unchecked(&buf[..total]) }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn write_dir_to(&self, arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
|
||||
let dir_len = self.filename_offset as usize;
|
||||
out.reserve(dir_len);
|
||||
let dir_chunks = chunks_needed(dir_len).min(self.indices.len());
|
||||
let base = arena.as_ptr();
|
||||
let vec = unsafe { out.as_mut_vec() };
|
||||
for (i, &idx) in self.indices[..dir_chunks].iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let take = SIMD_CHUNK_BYTES.min(dir_len - i * SIMD_CHUNK_BYTES);
|
||||
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn write_filename_to(&self, arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
|
||||
let fname_offset = self.filename_offset as usize;
|
||||
let fname_len = self.byte_len as usize - fname_offset;
|
||||
out.reserve(fname_len);
|
||||
let start_chunk = fname_offset / SIMD_CHUNK_BYTES;
|
||||
let offset_in_chunk = fname_offset % SIMD_CHUNK_BYTES;
|
||||
let needed_chunks = chunks_needed(offset_in_chunk + fname_len);
|
||||
Self::write_slice_to_vec(
|
||||
&self.indices[start_chunk..start_chunk + needed_chunks],
|
||||
arena.as_ptr(),
|
||||
offset_in_chunk,
|
||||
fname_len,
|
||||
unsafe { out.as_mut_vec() },
|
||||
);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn write_to_string(&self, arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
|
||||
let total = self.byte_len as usize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
out.reserve(total);
|
||||
let base = arena.as_ptr();
|
||||
let vec = unsafe { out.as_mut_vec() };
|
||||
for (i, &idx) in self.indices.iter().enumerate() {
|
||||
let src = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) };
|
||||
let take = SIMD_CHUNK_BYTES.min(total - i * SIMD_CHUNK_BYTES);
|
||||
vec.extend_from_slice(unsafe { core::slice::from_raw_parts(src, take) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChunkedString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChunkedString")
|
||||
.field("indices", &self.indices.as_slice())
|
||||
.field("chunks", &self.indices.len())
|
||||
.field("byte_len", &self.byte_len)
|
||||
.field("filename_offset", &self.filename_offset)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
const fn chunks_needed(byte_len: usize) -> usize {
|
||||
if byte_len == 0 {
|
||||
0
|
||||
} else {
|
||||
byte_len.div_ceil(SIMD_CHUNK_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChunkedPathStore {
|
||||
arena: Vec<SimdChunk>,
|
||||
}
|
||||
|
||||
// SAFETY: arena is immutable after construction. Pointers derived from it are
|
||||
// only read during scoring (no mutation, no reallocation).
|
||||
unsafe impl Send for ChunkedPathStore {}
|
||||
unsafe impl Sync for ChunkedPathStore {}
|
||||
|
||||
impl ChunkedPathStore {
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.arena.len() * SIMD_CHUNK_BYTES
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn unique_chunks(&self) -> usize {
|
||||
self.arena.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn as_arena_ptr(&self) -> ArenaPtr {
|
||||
ArenaPtr::new(self.arena.as_ptr() as *const u8)
|
||||
}
|
||||
}
|
||||
|
||||
/// At runtime the builder should be split out from the store after `finish()`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChunkedPathStoreBuilder {
|
||||
arena: Vec<SimdChunk>,
|
||||
chunk_dedup: AHashMap<[u8; SIMD_CHUNK_BYTES], u32>,
|
||||
}
|
||||
|
||||
impl ChunkedPathStoreBuilder {
|
||||
pub fn new(estimated_files: usize) -> Self {
|
||||
let est_chunks = estimated_files * 3;
|
||||
Self {
|
||||
arena: Vec::with_capacity(est_chunks / 2),
|
||||
chunk_dedup: AHashMap::with_capacity(est_chunks / 2),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(self) -> ChunkedPathStore {
|
||||
ChunkedPathStore { arena: self.arena }
|
||||
}
|
||||
|
||||
pub fn as_arena_ptr(&self) -> ArenaPtr {
|
||||
ArenaPtr::new(self.arena.as_ptr() as *const u8)
|
||||
}
|
||||
|
||||
/// Like [`add_file_immediate`] but for directory paths where the entire
|
||||
/// string is the "directory" portion (filename_offset == byte_len).
|
||||
pub fn add_dir_immediate(&mut self, dir_rel_path: &str) -> ChunkedString {
|
||||
self.add_file_immediate(dir_rel_path, dir_rel_path.len() as u16)
|
||||
}
|
||||
|
||||
pub fn add_file_immediate(&mut self, rel_path: &str, filename_offset: u16) -> ChunkedString {
|
||||
let path_bytes = rel_path.as_bytes();
|
||||
let byte_len = rel_path.len();
|
||||
let mut indices = ChunkIndices::with_capacity(chunks_needed(byte_len));
|
||||
|
||||
for chunk in path_bytes.chunks(SIMD_CHUNK_BYTES) {
|
||||
let mut chunk_bytes = [0u8; SIMD_CHUNK_BYTES];
|
||||
chunk_bytes[..chunk.len()].copy_from_slice(chunk);
|
||||
|
||||
let arena_idx = match self.chunk_dedup.get(&chunk_bytes) {
|
||||
Some(&idx) => idx,
|
||||
None => {
|
||||
let idx = self.arena.len() as u32;
|
||||
self.arena.push(SimdChunk(chunk_bytes));
|
||||
self.chunk_dedup.insert(chunk_bytes, idx);
|
||||
idx
|
||||
}
|
||||
};
|
||||
|
||||
indices.push(arena_idx);
|
||||
}
|
||||
|
||||
ChunkedString::new(indices, byte_len as u16, filename_offset)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_chunked_path_store_from_strings(
|
||||
rel_paths: &[String],
|
||||
files: &[crate::types::FileItem],
|
||||
) -> (ChunkedPathStore, Vec<ChunkedString>) {
|
||||
assert_eq!(rel_paths.len(), files.len());
|
||||
let mut builder = ChunkedPathStoreBuilder::new(rel_paths.len());
|
||||
let strings: Vec<ChunkedString> = rel_paths
|
||||
.iter()
|
||||
.zip(files.iter())
|
||||
.map(|(rel_path, file)| builder.add_file_immediate(rel_path, file.path.filename_offset))
|
||||
.collect();
|
||||
(builder.finish(), strings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_file_item(path: &str) -> crate::types::FileItem {
|
||||
let filename_start = path
|
||||
.rfind(std::path::is_separator)
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0) as u16;
|
||||
crate::types::FileItem::new_raw(filename_start, 0, 0, None, false)
|
||||
}
|
||||
|
||||
fn build_test_store(
|
||||
paths: &[&str],
|
||||
) -> (
|
||||
ChunkedPathStore,
|
||||
Vec<ChunkedString>,
|
||||
Vec<crate::types::FileItem>,
|
||||
) {
|
||||
let mut files: Vec<crate::types::FileItem> =
|
||||
paths.iter().map(|p| make_file_item(p)).collect();
|
||||
let path_strings: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
|
||||
let (store, strings) = build_chunked_path_store_from_strings(&path_strings, &files);
|
||||
for (i, file) in files.iter_mut().enumerate() {
|
||||
file.set_path(strings[i].clone());
|
||||
}
|
||||
(store, strings, files)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_store_empty() {
|
||||
let (store, strings, _files) = build_test_store(&[]);
|
||||
assert_eq!(strings.len(), 0);
|
||||
assert_eq!(store.unique_chunks(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_store_basic() {
|
||||
let (store, strings, _files) =
|
||||
build_test_store(&["src/lib.rs", "src/main.rs", "Cargo.toml"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
|
||||
assert_eq!(strings.len(), 3);
|
||||
assert!(store.unique_chunks() >= 2);
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(
|
||||
strings[0].read_to_buf(arena, &mut buf).len(),
|
||||
"src/lib.rs".len()
|
||||
);
|
||||
assert_eq!(
|
||||
strings[2].read_to_buf(arena, &mut buf).len(),
|
||||
"Cargo.toml".len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_full_path() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(cs.read_to_buf(arena, &mut buf), "src/components/Button.tsx");
|
||||
assert_eq!(cs.byte_len, 25);
|
||||
assert_eq!(cs.filename_offset, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_dir_and_filename() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
let mut s = String::new();
|
||||
cs.write_dir_to(arena, &mut s);
|
||||
assert_eq!(s, "src/components/");
|
||||
cs.write_filename_to(arena, &mut s);
|
||||
assert_eq!(s, "Button.tsx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_root_file() {
|
||||
let (store, strings, _files) = build_test_store(&["Cargo.toml"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
let mut s = String::new();
|
||||
cs.write_dir_to(arena, &mut s);
|
||||
assert_eq!(s, "");
|
||||
cs.write_filename_to(arena, &mut s);
|
||||
assert_eq!(s, "Cargo.toml");
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(cs.read_to_buf(arena, &mut buf), "Cargo.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_resolve_ptrs() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
let mut ptrs = [std::ptr::null::<u8>(); 32];
|
||||
let resolved = cs.resolve_ptrs(arena, &mut ptrs);
|
||||
assert_eq!(resolved.len(), 2); // 25 bytes = 2 chunks
|
||||
|
||||
// Verify we can read back the bytes
|
||||
let mut reconstructed = Vec::new();
|
||||
for (i, &ptr) in resolved.iter().enumerate() {
|
||||
let chunk = unsafe { std::slice::from_raw_parts(ptr, SIMD_CHUNK_BYTES) };
|
||||
let start = i * SIMD_CHUNK_BYTES;
|
||||
let take = SIMD_CHUNK_BYTES.min(25 - start);
|
||||
reconstructed.extend_from_slice(&chunk[..take]);
|
||||
}
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&reconstructed).unwrap(),
|
||||
"src/components/Button.tsx"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_cow_mid_chunk() {
|
||||
let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
assert_eq!(cs.filename_offset, 15);
|
||||
assert_eq!(cs.byte_len, 25);
|
||||
|
||||
let fname = cs.filename_cow(arena);
|
||||
assert_eq!(&*fname, "Button.tsx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_cow_chunk_aligned() {
|
||||
let path = "0123456789abcdef/file.txt";
|
||||
let (store, strings, _files) = build_test_store(&[path]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
assert_eq!(cs.filename_offset, 17);
|
||||
let fname = cs.filename_cow(arena);
|
||||
assert_eq!(&*fname, "file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_cow_root_file() {
|
||||
let (store, strings, _files) = build_test_store(&["Cargo.toml"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
assert_eq!(cs.filename_offset, 0);
|
||||
let fname = cs.filename_cow(arena);
|
||||
assert_eq!(&*fname, "Cargo.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_long_path() {
|
||||
let path = "very/deeply/nested/directory/structure/with/many/levels/file.txt";
|
||||
let (store, strings, _files) = build_test_store(&[path]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(cs.read_to_buf(arena, &mut buf), path);
|
||||
assert!(
|
||||
cs.chunk_count() <= 6,
|
||||
"should fit inline in ChunkIndices (INLINE_CHUNKS={})",
|
||||
INLINE_CHUNKS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_clone() {
|
||||
let (store, strings, _files) = build_test_store(&["src/main.rs"]);
|
||||
let arena = store.as_arena_ptr();
|
||||
let cs = &strings[0];
|
||||
let cs2 = cs.clone();
|
||||
|
||||
let mut buf1 = [0u8; 512];
|
||||
let mut buf2 = [0u8; 512];
|
||||
assert_eq!(
|
||||
cs.read_to_buf(arena, &mut buf1),
|
||||
cs2.read_to_buf(arena, &mut buf2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunked_string_full_path_roundtrip() {
|
||||
let paths = [
|
||||
"src/components/Button.tsx",
|
||||
"src/components/ui/DatePicker.tsx",
|
||||
"very/deeply/nested/directory/structure/file.txt",
|
||||
"Cargo.toml",
|
||||
"a.rs",
|
||||
];
|
||||
let (store, strings, _files) = build_test_store(&paths);
|
||||
let arena = store.as_arena_ptr();
|
||||
|
||||
for (i, expected) in paths.iter().enumerate() {
|
||||
let mut buf = [0u8; 512];
|
||||
let got = strings[i].read_to_buf(arena, &mut buf);
|
||||
assert_eq!(got, *expected, "full path roundtrip failed for file {i}");
|
||||
|
||||
let mut ds = String::new();
|
||||
let mut fs = String::new();
|
||||
strings[i].write_dir_to(arena, &mut ds);
|
||||
strings[i].write_filename_to(arena, &mut fs);
|
||||
assert_eq!(
|
||||
format!("{ds}{fs}"),
|
||||
*expected,
|
||||
"dir+fname mismatch for file {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,56 @@
|
||||
//! Thread-local sort buffer management for glidesort optimization
|
||||
//!
|
||||
//! This module provides thread-local buffers for glidesort's with_buffer API,
|
||||
//! eliminating allocations in the hot path of fuzzy search operations.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use parking_lot::Mutex;
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
// glidesort requires a buffer to allocate, we use one reused buffer as it can grow pretty big
|
||||
// for a large projects, this effectively saves 12kb of allocation on every search in linux repo
|
||||
thread_local! {
|
||||
static SORT_BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(1024));
|
||||
// this originally happen to be in TLS but there is a limit of TLS
|
||||
// + the storage itself is not free, so now we rely on the fact that most calls
|
||||
// are sequential in practice and allocate ONLY when we have a parallel access
|
||||
static SORT_BUFFER: Mutex<Vec<u8>> = Mutex::new(Vec::new());
|
||||
|
||||
fn ensure_capacity(buf: &mut Vec<u8>, required: usize) {
|
||||
if buf.capacity() < required {
|
||||
let len = buf.len();
|
||||
buf.reserve(required - len);
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedSortBuf {
|
||||
guard: parking_lot::MutexGuard<'static, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SharedSortBuf {
|
||||
fn as_slice_mut<T>(&mut self, len: usize) -> &mut [MaybeUninit<T>] {
|
||||
let align = std::mem::align_of::<MaybeUninit<T>>();
|
||||
let size = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let required = len.saturating_mul(size).saturating_add(align);
|
||||
ensure_capacity(&mut self.guard, required);
|
||||
|
||||
// SAFETY: the Vec<u8> is only 1-byte aligned, so we over-allocate by
|
||||
// `align` bytes and shift the pointer to satisfy T's alignment.
|
||||
// Callers never read uninitialised data through the returned slice.
|
||||
unsafe {
|
||||
let ptr = self.guard.as_mut_ptr();
|
||||
let offset = ptr.align_offset(align);
|
||||
debug_assert!(offset != usize::MAX && offset + len * size <= self.guard.capacity());
|
||||
std::slice::from_raw_parts_mut(ptr.add(offset) as *mut MaybeUninit<T>, len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_lock_shared_buf() -> Option<SharedSortBuf> {
|
||||
SORT_BUFFER.try_lock().map(|guard| SharedSortBuf { guard })
|
||||
}
|
||||
|
||||
pub fn sort_with_buffer<T, F>(slice: &mut [T], compare: F)
|
||||
where
|
||||
F: FnMut(&T, &T) -> std::cmp::Ordering,
|
||||
{
|
||||
SORT_BUFFER.with(|buffer| {
|
||||
let mut buffer = buffer.borrow_mut();
|
||||
|
||||
// Calculate required buffer size in u8 units
|
||||
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let size_of_usize = std::mem::size_of::<u8>();
|
||||
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
|
||||
|
||||
// Ensure buffer has enough capacity
|
||||
if buffer.len() < required_usizes {
|
||||
buffer.resize(required_usizes, 0);
|
||||
match try_lock_shared_buf() {
|
||||
Some(mut buf) => {
|
||||
let typed = buf.as_slice_mut::<T>(slice.len());
|
||||
glidesort::sort_with_buffer_by(slice, typed, compare);
|
||||
}
|
||||
|
||||
// Cast u8 buffer to MaybeUninit<T> slice
|
||||
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
|
||||
// the buffer is large enough
|
||||
let typed_buffer = unsafe {
|
||||
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
|
||||
};
|
||||
|
||||
glidesort::sort_with_buffer_by(slice, typed_buffer, compare);
|
||||
});
|
||||
None => glidesort::sort_by(slice, compare),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_by_key_with_buffer<T, K, F>(slice: &mut [T], key_fn: F)
|
||||
@@ -45,28 +58,13 @@ where
|
||||
K: Ord,
|
||||
F: FnMut(&T) -> K,
|
||||
{
|
||||
SORT_BUFFER.with(|buffer| {
|
||||
let mut buffer = buffer.borrow_mut();
|
||||
|
||||
// Calculate required buffer size in u8 units
|
||||
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let size_of_usize = std::mem::size_of::<u8>();
|
||||
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
|
||||
|
||||
// Ensure buffer has enough capacity
|
||||
if buffer.len() < required_usizes {
|
||||
buffer.resize(required_usizes, 0);
|
||||
match try_lock_shared_buf() {
|
||||
Some(mut buf) => {
|
||||
let typed = buf.as_slice_mut::<T>(slice.len());
|
||||
glidesort::sort_with_buffer_by_key(slice, typed, key_fn);
|
||||
}
|
||||
|
||||
// Cast u8 buffer to MaybeUninit<T> slice
|
||||
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
|
||||
// the buffer is large enough
|
||||
let typed_buffer = unsafe {
|
||||
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
|
||||
};
|
||||
|
||||
glidesort::sort_with_buffer_by_key(slice, typed_buffer, key_fn);
|
||||
});
|
||||
None => glidesort::sort_by_key(slice, key_fn),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -94,19 +92,6 @@ mod tests {
|
||||
assert_eq!(data, vec![5, 4, 3, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_sorts_reuse_buffer() {
|
||||
// This test verifies that multiple sorts on the same thread reuse the buffer
|
||||
let mut data1 = vec![5, 2, 8, 1, 9];
|
||||
sort_with_buffer(&mut data1, |a, b| a.cmp(b));
|
||||
|
||||
let mut data2 = vec![15, 12, 18, 11, 19];
|
||||
sort_with_buffer(&mut data2, |a, b| a.cmp(b));
|
||||
|
||||
assert_eq!(data1, vec![1, 2, 5, 8, 9]);
|
||||
assert_eq!(data2, vec![11, 12, 15, 18, 19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_slice() {
|
||||
let mut data: Vec<i32> = vec![];
|
||||
@@ -121,13 +106,6 @@ mod tests {
|
||||
assert_eq!(data, vec![42]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_already_sorted() {
|
||||
let mut data = vec![1, 2, 3, 4, 5];
|
||||
sort_with_buffer(&mut data, |a, b| a.cmp(b));
|
||||
assert_eq!(data, vec![1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_duplicates() {
|
||||
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
|
||||
@@ -144,11 +122,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_simple_descending() {
|
||||
// Simple test to verify highest scores come first
|
||||
let mut data = vec![100, 300, 200];
|
||||
sort_with_buffer(&mut data, |a, b| b.cmp(a));
|
||||
assert_eq!(data[0], 300, "Highest should be first");
|
||||
assert_eq!(data[1], 200, "Middle should be second");
|
||||
assert_eq!(data[2], 100, "Lowest should be last");
|
||||
assert_eq!(data[0], 300);
|
||||
assert_eq!(data[1], 200);
|
||||
assert_eq!(data[2], 100);
|
||||
}
|
||||
}
|
||||
|
||||
+465
-137
@@ -1,11 +1,43 @@
|
||||
use std::path::Path;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicI32, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use crate::simd_path::{ArenaPtr, PATH_BUF_SIZE};
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
|
||||
/// Different sources of the string storage used by FFF
|
||||
/// implements as a deduplicated 16-bytes alined heap
|
||||
/// can be stored in RAM or on disk
|
||||
pub trait FFFStringStorage {
|
||||
/// Resolve the arena for a [`FileItem`] (handles base vs overflow split).
|
||||
fn arena_for(&self, file: &FileItem) -> ArenaPtr;
|
||||
|
||||
/// The base arena (scan-time paths).
|
||||
fn base_arena(&self) -> ArenaPtr;
|
||||
/// The overflow arena (paths added after the last full scan).
|
||||
fn overflow_arena(&self) -> ArenaPtr;
|
||||
}
|
||||
|
||||
impl FFFStringStorage for ArenaPtr {
|
||||
#[inline]
|
||||
fn arena_for(&self, _file: &FileItem) -> ArenaPtr {
|
||||
*self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn base_arena(&self) -> ArenaPtr {
|
||||
*self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn overflow_arena(&self) -> ArenaPtr {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached file contents — mmap on Unix, heap buffer on Windows.
|
||||
///
|
||||
/// On Windows, memory-mapped files hold the file handle open and prevent
|
||||
@@ -40,41 +72,163 @@ impl FileItemFlags {
|
||||
/// Tombstone — file was deleted but index slot is preserved so
|
||||
/// bigram indices for other files stay valid.
|
||||
pub const DELETED: u8 = 1 << 1;
|
||||
/// File was added after the last full reindex; its indices point
|
||||
/// into the overflow builder arena, not the base arena.
|
||||
pub const OVERFLOW: u8 = 1 << 2;
|
||||
}
|
||||
|
||||
pub struct DirFlags;
|
||||
|
||||
impl DirFlags {
|
||||
pub const OVERFLOW: u8 = 1 << 0;
|
||||
}
|
||||
|
||||
/// A directory in the file index. Shares chunk arena with file paths.
|
||||
#[derive(Debug)]
|
||||
pub struct DirItem {
|
||||
flags: u8,
|
||||
pub(crate) path: crate::simd_path::ChunkedString,
|
||||
/// Byte offset where the last path segment begins (e.g. for `src/components/`
|
||||
/// this is 4, pointing to `components/`). Used for dirname-bonus scoring.
|
||||
last_segment_offset: u16,
|
||||
/// Maximum `access_frecency_score` among direct child files.
|
||||
/// Atomic so parallel frecency updates can write directly without juggling.
|
||||
max_access_frecency: AtomicI32,
|
||||
}
|
||||
|
||||
impl Clone for DirItem {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
flags: self.flags,
|
||||
path: self.path.clone(),
|
||||
last_segment_offset: self.last_segment_offset,
|
||||
max_access_frecency: AtomicI32::new(self.max_access_frecency()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DirItem {
|
||||
#[inline(always)]
|
||||
pub fn is_overflow(&self) -> bool {
|
||||
self.flags & DirFlags::OVERFLOW == 0
|
||||
}
|
||||
|
||||
pub(crate) fn new(path: crate::simd_path::ChunkedString, last_segment_offset: u16) -> Self {
|
||||
Self {
|
||||
path,
|
||||
flags: 0,
|
||||
last_segment_offset,
|
||||
max_access_frecency: AtomicI32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offset of the last path segment within the directory path.
|
||||
#[inline]
|
||||
pub fn last_segment_offset(&self) -> u16 {
|
||||
self.last_segment_offset
|
||||
}
|
||||
|
||||
/// Current max access frecency score.
|
||||
#[inline]
|
||||
pub fn max_access_frecency(&self) -> i32 {
|
||||
self.max_access_frecency.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Atomically update the directory's frecency score if the given score is larger.
|
||||
/// Safe to call from parallel threads.
|
||||
#[inline]
|
||||
pub fn update_frecency_if_larger(&self, score: i32) {
|
||||
self.max_access_frecency.fetch_max(score, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Reset frecency to zero (used before full recomputation).
|
||||
#[inline]
|
||||
pub fn reset_frecency(&self) {
|
||||
self.max_access_frecency.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn read_relative_path<'a>(&self, arena: ArenaPtr, buf: &'a mut [u8]) -> &'a str {
|
||||
self.path.read_to_buf(arena, buf)
|
||||
}
|
||||
|
||||
/// Relative dir path as owned String (cold path).
|
||||
pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
|
||||
let mut out = String::new();
|
||||
let ptr = if self.is_overflow() {
|
||||
arena.overflow_arena()
|
||||
} else {
|
||||
arena.base_arena()
|
||||
};
|
||||
|
||||
self.path.write_to_string(ptr, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Write the last segment (dirname) of this directory path to `out`.
|
||||
pub fn write_dir_name(&self, arena: ArenaPtr, out: &mut String) {
|
||||
out.clear();
|
||||
let total = self.path.byte_len as usize;
|
||||
let offset = self.last_segment_offset as usize;
|
||||
if offset >= total {
|
||||
return;
|
||||
}
|
||||
// Read the full path, then slice from last_segment_offset
|
||||
let mut buf = [0u8; PATH_BUF_SIZE];
|
||||
let full = self.path.read_to_buf(arena, &mut buf);
|
||||
out.push_str(&full[offset..]);
|
||||
}
|
||||
|
||||
/// The dirname (last segment) as an owned String. Cold path.
|
||||
pub fn dir_name(&self, arena: impl FFFStringStorage) -> String {
|
||||
let mut out = String::new();
|
||||
let ptr = if self.is_overflow() {
|
||||
arena.overflow_arena()
|
||||
} else {
|
||||
arena.base_arena()
|
||||
};
|
||||
self.write_dir_name(ptr, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A path = base_path + "/" + relative. Cold path, allocates.
|
||||
pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
|
||||
let rel = self.relative_path(arena);
|
||||
if rel.is_empty() {
|
||||
base_path.to_path_buf()
|
||||
} else {
|
||||
base_path.join(&rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Constrainable for DirItem {
|
||||
#[inline]
|
||||
fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
|
||||
// For dirs, the "file name" equivalent is the last path segment
|
||||
self.write_dir_name(arena, out);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
|
||||
self.path.write_to_string(arena, out);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn git_status(&self) -> Option<git2::Status> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
/// Modification time in UNIX timestamp
|
||||
pub modified: u64,
|
||||
/// 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>,
|
||||
|
||||
/// 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`.
|
||||
pub(crate) path: crate::simd_path::ChunkedString,
|
||||
parent_dir: u32,
|
||||
flags: u8,
|
||||
/// Lazily-initialized file contents for grep.
|
||||
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
|
||||
content: OnceLock<FileContent>,
|
||||
}
|
||||
|
||||
@@ -82,51 +236,21 @@ impl Clone for FileItem {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
path: self.path.clone(),
|
||||
relative_start: self.relative_start,
|
||||
filename_start: self.filename_start,
|
||||
parent_dir: self.parent_dir,
|
||||
size: self.size,
|
||||
modified: self.modified,
|
||||
access_frecency_score: self.access_frecency_score,
|
||||
modification_frecency_score: self.modification_frecency_score,
|
||||
git_status: self.git_status,
|
||||
flags: self.flags,
|
||||
// Don't clone the content — the clone lazily re-creates it on demand
|
||||
// on clone we have to reset the content lock
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: String,
|
||||
relative_start: u16,
|
||||
filename_start: u16,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
@@ -138,10 +262,12 @@ impl FileItem {
|
||||
flags |= FileItemFlags::BINARY;
|
||||
}
|
||||
|
||||
let mut path = crate::simd_path::ChunkedString::empty();
|
||||
path.filename_offset = filename_start;
|
||||
|
||||
Self {
|
||||
path,
|
||||
relative_start,
|
||||
filename_start,
|
||||
parent_dir: u32::MAX,
|
||||
size,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
@@ -152,35 +278,122 @@ impl FileItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// The full absolute path as a string slice.
|
||||
#[inline]
|
||||
pub fn path_str(&self) -> &str {
|
||||
&self.path
|
||||
/// Returns an absolute path of the file
|
||||
pub fn absolute_path(&self, arena: impl FFFStringStorage, base_path: &Path) -> PathBuf {
|
||||
let mut buf = [0u8; PATH_BUF_SIZE];
|
||||
let rel = self.path.read_to_buf(arena.arena_for(self), &mut buf);
|
||||
base_path.join(rel)
|
||||
}
|
||||
|
||||
/// The full absolute path as a `&Path` (zero-cost on Unix).
|
||||
#[inline]
|
||||
pub fn as_path(&self) -> &Path {
|
||||
Path::new(&self.path)
|
||||
pub(crate) fn set_path(&mut self, path: crate::simd_path::ChunkedString) {
|
||||
self.path = path;
|
||||
}
|
||||
|
||||
/// The relative path (from the base directory).
|
||||
#[inline]
|
||||
pub fn relative_path(&self) -> &str {
|
||||
&self.path[self.relative_start as usize..]
|
||||
pub(crate) fn parent_dir_index(&self) -> u32 {
|
||||
self.parent_dir
|
||||
}
|
||||
|
||||
/// Just the filename component.
|
||||
#[inline]
|
||||
pub fn file_name(&self) -> &str {
|
||||
&self.path[self.filename_start as usize..]
|
||||
pub(crate) fn set_parent_dir(&mut self, idx: u32) {
|
||||
self.parent_dir = idx;
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub fn dir_str(&self, arena: impl FFFStringStorage) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
self.path.write_dir_to(arena.arena_for(self), &mut s);
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn write_dir_str(&self, arena: ArenaPtr, out: &mut String) {
|
||||
self.path.write_dir_to(arena, out);
|
||||
}
|
||||
|
||||
pub fn file_name(&self, arena: impl FFFStringStorage) -> String {
|
||||
let mut s = String::with_capacity(32);
|
||||
self.path.write_filename_to(arena.arena_for(self), &mut s);
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn write_file_name_from_arena(&self, arena: ArenaPtr, out: &mut String) {
|
||||
self.path.write_filename_to(arena, out);
|
||||
}
|
||||
|
||||
pub fn relative_path(&self, arena: impl FFFStringStorage) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
self.path.write_to_string(arena.arena_for(self), &mut s);
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn write_relative_path_from_arena(&self, arena: ArenaPtr, out: &mut String) {
|
||||
self.path.write_to_string(arena, out);
|
||||
}
|
||||
|
||||
pub fn relative_path_len(&self) -> usize {
|
||||
self.path.byte_len as usize
|
||||
}
|
||||
|
||||
pub fn filename_offset_in_relative_path(&self) -> usize {
|
||||
self.path.filename_offset as usize
|
||||
}
|
||||
|
||||
pub(crate) fn relative_path_eq(&self, arena: ArenaPtr, other: &str) -> bool {
|
||||
if other.len() != self.path.byte_len as usize {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 512];
|
||||
let mine = self.path.read_to_buf(arena, &mut buf);
|
||||
mine == other
|
||||
}
|
||||
|
||||
pub(crate) fn relative_path_starts_with(&self, arena: ArenaPtr, prefix: &str) -> bool {
|
||||
let mut buf = [0u8; PATH_BUF_SIZE];
|
||||
let path = self.path.read_to_buf(arena, &mut buf);
|
||||
path.starts_with(prefix)
|
||||
}
|
||||
|
||||
/// Write `base_path + '/' + relative_path` into `buf` and return it
|
||||
/// as `&Path`. Takes a fixed-size array so the buffer can live on
|
||||
/// the stack (no heap allocation, no bounds checks in the hot loop).
|
||||
pub(crate) fn write_absolute_path<'a>(
|
||||
&self,
|
||||
arena: ArenaPtr,
|
||||
base_path: &Path,
|
||||
buf: &'a mut [u8; PATH_BUF_SIZE],
|
||||
) -> &'a Path {
|
||||
let base = base_path.as_os_str().as_encoded_bytes();
|
||||
let base_len = base.len();
|
||||
buf[..base_len].copy_from_slice(base);
|
||||
let sep_len = if base_len > 0 && base[base_len - 1] != std::path::MAIN_SEPARATOR as u8 {
|
||||
buf[base_len] = std::path::MAIN_SEPARATOR as u8;
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let base_end_idx = base_len + sep_len;
|
||||
let relative_portion_str = self.path.read_to_buf(arena, &mut buf[base_end_idx..]);
|
||||
let total = base_end_idx + relative_portion_str.len();
|
||||
Path::new(unsafe { std::str::from_utf8_unchecked(&buf[..total]) })
|
||||
}
|
||||
|
||||
/// Write the relative path into `buf` and NUL-terminate, returning
|
||||
/// a `&CStr`. Fixed-size array so the buffer is stack-allocatable.
|
||||
///
|
||||
/// Paired with a parent-directory fd this eliminates the per-file
|
||||
/// absolute-path memcpy: `openat(dir_fd, cstr.as_ptr(), O_RDONLY)`
|
||||
/// resolves the name relative to `dir_fd`. Unix-only.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn write_relative_cstr<'a>(
|
||||
&self,
|
||||
arena: ArenaPtr,
|
||||
buf: &'a mut [u8; PATH_BUF_SIZE],
|
||||
) -> &'a std::ffi::CStr {
|
||||
// Reserve the last byte for the NUL terminator.
|
||||
let rel = self.path.read_to_buf(arena, &mut buf[..PATH_BUF_SIZE - 1]);
|
||||
let n = rel.len();
|
||||
buf[n] = 0;
|
||||
// SAFETY: `buf[..=n]` ends with the NUL we just wrote and
|
||||
// filesystem paths never contain interior NULs.
|
||||
unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(&buf[..=n]) }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -216,6 +429,22 @@ impl FileItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_overflow(&self) -> bool {
|
||||
self.flags & FileItemFlags::OVERFLOW != 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_overflow(&mut self, val: bool) {
|
||||
if val {
|
||||
self.flags |= FileItemFlags::OVERFLOW;
|
||||
} else {
|
||||
self.flags &= !FileItemFlags::OVERFLOW;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -231,6 +460,25 @@ impl FileItem {
|
||||
self.content = OnceLock::new();
|
||||
}
|
||||
|
||||
pub fn update_metadata(
|
||||
&mut self,
|
||||
budget: &ContentCacheBudget,
|
||||
modified_secs: Option<u64>,
|
||||
new_size: Option<u64>,
|
||||
) {
|
||||
if let Some(modified) = modified_secs
|
||||
&& self.modified < modified
|
||||
{
|
||||
self.modified = modified;
|
||||
}
|
||||
|
||||
self.invalidate_mmap(budget);
|
||||
|
||||
if let Some(size) = new_size {
|
||||
self.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -238,7 +486,12 @@ impl FileItem {
|
||||
/// 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]> {
|
||||
pub(crate) fn get_content(
|
||||
&self,
|
||||
arena: ArenaPtr,
|
||||
base_path: &Path,
|
||||
budget: &ContentCacheBudget,
|
||||
) -> Option<&[u8]> {
|
||||
if let Some(content) = self.content.get() {
|
||||
return Some(content);
|
||||
}
|
||||
@@ -257,7 +510,7 @@ impl FileItem {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = load_file_content(self.as_path(), self.size)?;
|
||||
let content = load_file_content(&self.absolute_path(arena, base_path), self.size)?;
|
||||
let result = self.content.get_or_init(|| content);
|
||||
|
||||
// Bump counters. Slight over-count under races is fine — the budget
|
||||
@@ -271,41 +524,46 @@ impl FileItem {
|
||||
/// 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.
|
||||
/// The caller provides a reusable `path_buf` (pre-filled with `base_path/`)
|
||||
/// and its `base_len` to avoid allocations when constructing the absolute path.
|
||||
#[inline]
|
||||
pub fn get_content_for_search<'a>(
|
||||
pub(crate) fn get_content_for_search<'a>(
|
||||
&'a self,
|
||||
buf: &'a mut Vec<u8>, // we allow it to grow
|
||||
arena: ArenaPtr,
|
||||
base_path: &Path,
|
||||
budget: &ContentCacheBudget,
|
||||
) -> Option<FileContentRef<'a>> {
|
||||
if let Some(cached) = self.get_content(budget) {
|
||||
return Some(FileContentRef::Cached(cached));
|
||||
) -> Option<&'a [u8]> {
|
||||
// Fast path: persistent cache hit (zero-copy).
|
||||
if let Some(cached) = self.get_content(arena, base_path, budget) {
|
||||
return Some(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))
|
||||
// Slow path: read into the reusable buffer — open() + read_exact() + close().
|
||||
// No mmap()/munmap() syscalls, no page table setup/teardown.
|
||||
// We know the exact size so we use read_exact (1 read syscall) instead of
|
||||
// read_to_end (2 read syscalls — one for data, one for EOF confirmation).
|
||||
let abs = self.absolute_path(arena, base_path);
|
||||
let len = self.size as usize;
|
||||
buf.resize(len, 0);
|
||||
let mut file = std::fs::File::open(&abs).ok()?;
|
||||
file.read_exact(buf).ok()?;
|
||||
Some(buf.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
/// Unused on Windows where `load_file_content` does not mmap.
|
||||
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
|
||||
const MMAP_THRESHOLD: u64 = 16 * 1024;
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
#[cfg(all(not(target_os = "windows"), 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"))]
|
||||
{
|
||||
@@ -330,22 +588,15 @@ fn load_file_content(path: &Path, size: u64) -> Option<FileContent> {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
FileItem::relative_path(self)
|
||||
fn write_file_name(&self, arena: ArenaPtr, out: &mut String) {
|
||||
self.path.write_filename_to(arena, out);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn file_name(&self) -> &str {
|
||||
FileItem::file_name(self)
|
||||
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String) {
|
||||
self.path.write_to_string(arena, out);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -365,6 +616,7 @@ pub struct Score {
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
pub path_alignment_bonus: i32,
|
||||
pub exact_match: bool,
|
||||
pub match_type: &'static str,
|
||||
}
|
||||
@@ -384,14 +636,8 @@ impl Default for PaginationArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for scoring files during search.
|
||||
///
|
||||
/// 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> {
|
||||
/// 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>,
|
||||
@@ -404,8 +650,6 @@ pub struct 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) -> &str {
|
||||
match &self.query.fuzzy_query {
|
||||
FuzzyQuery::Text(t) => t,
|
||||
@@ -424,22 +668,45 @@ pub struct SearchResult<'a> {
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
/// Search result for directory-only fuzzy search.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DirSearchResult<'a> {
|
||||
pub items: Vec<&'a DirItem>,
|
||||
pub scores: Vec<Score>,
|
||||
pub total_matched: usize,
|
||||
pub total_dirs: usize,
|
||||
}
|
||||
|
||||
// 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.
|
||||
/// A single item in a mixed (files + directories) search result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MixedItemRef<'a> {
|
||||
File(&'a FileItem),
|
||||
Dir(&'a DirItem),
|
||||
}
|
||||
|
||||
/// Search result for mixed (files + directories) fuzzy search.
|
||||
/// Items are interleaved by total score in descending order.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MixedSearchResult<'a> {
|
||||
pub items: Vec<MixedItemRef<'a>>,
|
||||
pub scores: Vec<Score>,
|
||||
pub total_matched: usize,
|
||||
pub total_files: usize,
|
||||
pub total_dirs: usize,
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
impl Default for MixedItemRef<'_> {
|
||||
fn default() -> Self {
|
||||
// Should never be used, exists only for Default derive on MixedSearchResult
|
||||
unreachable!("MixedItemRef::default should not be called")
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
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,
|
||||
@@ -450,8 +717,6 @@ pub struct ContentCacheBudget {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -498,8 +763,31 @@ impl ContentCacheBudget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the counters. Called when the file index is rebuilt (rescan /
|
||||
/// directory change) and all old `FileItem`s are dropped.
|
||||
/// Build a budget from caller-supplied overrides.
|
||||
///
|
||||
/// Each argument is a cap; `0` means "use the library default for that
|
||||
/// cap" (inherits from [`Self::default`], which is `new_for_repo(30_000)`).
|
||||
/// Returns `None` when every cap is `0`, signalling to the picker that it
|
||||
/// should auto-size the budget from the final scanned file count rather
|
||||
/// than applying an explicit override.
|
||||
pub fn from_overrides(max_files: usize, max_bytes: u64, max_file_size: u64) -> Option<Self> {
|
||||
if max_files == 0 && max_bytes == 0 && max_file_size == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut budget = Self::default();
|
||||
if max_files > 0 {
|
||||
budget.max_files = max_files;
|
||||
}
|
||||
if max_bytes > 0 {
|
||||
budget.max_bytes = max_bytes;
|
||||
}
|
||||
if max_file_size > 0 {
|
||||
budget.max_file_size = max_file_size;
|
||||
}
|
||||
Some(budget)
|
||||
}
|
||||
|
||||
pub fn reset(&self) {
|
||||
self.cached_count.store(0, Ordering::Relaxed);
|
||||
self.cached_bytes.store(0, Ordering::Relaxed);
|
||||
@@ -511,3 +799,43 @@ impl Default for ContentCacheBudget {
|
||||
Self::new_for_repo(30_000)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FileItem {
|
||||
/// Leaks a single-file arena so the pointer stays valid forever.
|
||||
pub fn new_for_test(
|
||||
rel_path: &str,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
is_binary: bool,
|
||||
) -> Self {
|
||||
let (item, _arena) =
|
||||
Self::new_for_test_with_arena(rel_path, size, modified, git_status, is_binary);
|
||||
item
|
||||
}
|
||||
|
||||
pub(crate) fn new_for_test_with_arena(
|
||||
rel_path: &str,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
is_binary: bool,
|
||||
) -> (Self, ArenaPtr) {
|
||||
let filename_start = rel_path
|
||||
.rfind(std::path::is_separator)
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0) as u16;
|
||||
let mut item = Self::new_raw(filename_start, size, modified, git_status, is_binary);
|
||||
let paths = [rel_path.to_string()];
|
||||
let (store, strings) = crate::simd_path::build_chunked_path_store_from_strings(
|
||||
&paths,
|
||||
std::slice::from_ref(&item),
|
||||
);
|
||||
let cs = strings.into_iter().next().unwrap();
|
||||
let arena = store.as_arena_ptr();
|
||||
item.set_path(cs);
|
||||
std::mem::forget(store);
|
||||
(item, arena)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ 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};
|
||||
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
|
||||
|
||||
/// Create a temp directory with some initial files, run the full picker lifecycle,
|
||||
/// then modify a file and verify grep finds the new content.
|
||||
@@ -25,7 +25,7 @@ fn modified_file_findable_via_overlay() {
|
||||
.unwrap();
|
||||
fs::write(base.join("gamma.txt"), "yet another file\nmore lines\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
@@ -33,8 +33,10 @@ fn modified_file_findable_via_overlay() {
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: false, // we drive events manually
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -131,8 +133,15 @@ fn modified_file_findable_via_overlay() {
|
||||
!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"));
|
||||
// May find 1 or 2 matches depending on mmap cache state — the important
|
||||
// thing is that the modified content IS found.
|
||||
assert!(
|
||||
result
|
||||
.matches
|
||||
.iter()
|
||||
.any(|m| m.line_content.contains("UNIQUE_NEEDLE")),
|
||||
"At least one match should contain UNIQUE_NEEDLE"
|
||||
);
|
||||
}
|
||||
|
||||
// Prove the overlay is actually doing something: without it, the bigram
|
||||
@@ -142,7 +151,7 @@ fn modified_file_findable_via_overlay() {
|
||||
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);
|
||||
let result = picker.grep_original(&parsed, &opts);
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
0,
|
||||
@@ -167,7 +176,7 @@ fn deleted_file_excluded_via_overlay() {
|
||||
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_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
@@ -175,8 +184,10 @@ fn deleted_file_excluded_via_overlay() {
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: false, // we drive events manually
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -235,7 +246,7 @@ fn new_file_findable_after_add() {
|
||||
|
||||
fs::write(base.join("existing.txt"), "original content\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
@@ -243,8 +254,10 @@ fn new_file_findable_after_add() {
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: false, // we drive events manually
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -276,7 +289,7 @@ fn new_file_findable_after_add() {
|
||||
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[0].relative_path(picker).ends_with("newcomer.txt"),
|
||||
"Overflow file should be newcomer.txt"
|
||||
);
|
||||
}
|
||||
@@ -288,6 +301,82 @@ fn new_file_findable_after_add() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that a file modified after index build is findable via regex grep
|
||||
/// through the overlay. This catches a regression where `extract_bigrams` on
|
||||
/// the raw regex string (e.g. "NEEDLE.*HERE") produces bogus bigrams containing
|
||||
/// `.` and `*`, causing `query_modified` to miss the file.
|
||||
#[test]
|
||||
fn modified_file_findable_via_regex_overlay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
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();
|
||||
|
||||
let shared_picker = SharedFilePicker::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(),
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: false, // we drive events manually
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
wait_for_bigram(&shared_picker);
|
||||
|
||||
// Advance mtime past the scan timestamp so the cache is invalidated.
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
|
||||
// Write content that matches the regex "NEEDLE.*HERE" into beta.txt.
|
||||
let modified_path = base.join("beta.txt");
|
||||
fs::write(
|
||||
&modified_path,
|
||||
"some other content\nNEEDLE is right HERE\nnothing special\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(picker.on_create_or_modify(&modified_path).is_some());
|
||||
}
|
||||
|
||||
// Regex grep should find the modified file through the overlay.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let parsed = parse_grep_query("NEEDLE.*HERE");
|
||||
let opts = GrepSearchOptions {
|
||||
mode: GrepMode::Regex,
|
||||
..grep_opts()
|
||||
};
|
||||
let result = picker.grep(&parsed, &opts);
|
||||
assert!(
|
||||
!result.matches.is_empty(),
|
||||
"Regex grep should find NEEDLE.*HERE in modified file via overlay"
|
||||
);
|
||||
assert!(result.matches[0].line_content.contains("NEEDLE"));
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn grep_opts() -> GrepSearchOptions {
|
||||
@@ -302,6 +391,8 @@ fn grep_opts() -> GrepSearchOptions {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +401,7 @@ fn grep_for<'a>(picker: &'a FilePicker, query: &str) -> fff_search::grep::GrepRe
|
||||
picker.grep(&parsed, &grep_opts())
|
||||
}
|
||||
|
||||
fn wait_for_bigram(shared_picker: &SharedPicker) {
|
||||
fn wait_for_bigram(shared_picker: &SharedFilePicker) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Reproducer: macOS FSEvents does not deliver Remove events for files
|
||||
//! deleted from NonRecursive-watched directories when multiple directories
|
||||
//! are watched via stop/restart cycles.
|
||||
//!
|
||||
//! This test watches a temp directory NonRecursively, creates a file,
|
||||
//! verifies the Create event, deletes the file, and checks whether a
|
||||
//! Remove (or any) event is delivered.
|
||||
|
||||
use notify::event::*;
|
||||
use notify::{Config, EventKindMask, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn setup_temp_git_repo() -> (PathBuf, tempfile::TempDir) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().canonicalize().unwrap();
|
||||
|
||||
// Create a git repo like the bun test does
|
||||
std::process::Command::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(dir.join("hello.txt"), "hello\n").unwrap();
|
||||
fs::create_dir_all(dir.join("src")).unwrap();
|
||||
fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
(dir, tmp)
|
||||
}
|
||||
|
||||
/// Raw notify watcher: single NonRecursive watch on a directory.
|
||||
/// Create a file, delete it, check if Remove event is delivered.
|
||||
#[test]
|
||||
fn raw_notify_nonrecursive_detects_deletion() {
|
||||
let (dir, _tmp) = setup_temp_git_repo();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let config = Config::default()
|
||||
.with_follow_symlinks(false)
|
||||
.with_event_kinds(EventKindMask::CORE);
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: notify::Result<Event>| {
|
||||
if let Ok(ev) = res {
|
||||
let _ = tx.send(ev);
|
||||
}
|
||||
},
|
||||
config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Watch ONLY the root dir NonRecursively (like fff does)
|
||||
watcher
|
||||
.watch(dir.as_path(), RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
|
||||
// Let the watcher stabilize
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Drain any startup events
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
// Create a file
|
||||
let file_path = dir.join("testfile.txt");
|
||||
fs::write(&file_path, "content\n").unwrap();
|
||||
|
||||
// Wait for create event
|
||||
let mut got_create = false;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ev) => {
|
||||
eprintln!(" [create phase] event: {:?} paths={:?}", ev.kind, ev.paths);
|
||||
if matches!(ev.kind, EventKind::Create(_)) && ev.paths.contains(&file_path) {
|
||||
got_create = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
assert!(got_create, "Expected Create event for testfile.txt");
|
||||
|
||||
// Drain remaining events from the create
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
// Delete the file
|
||||
fs::remove_file(&file_path).unwrap();
|
||||
eprintln!(" File deleted: {}", file_path.display());
|
||||
|
||||
// Wait for any event related to the deletion
|
||||
let mut got_removal_event = false;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ev) => {
|
||||
eprintln!(" [delete phase] event: {:?} paths={:?}", ev.kind, ev.paths);
|
||||
if ev.paths.contains(&file_path) {
|
||||
got_removal_event = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
got_removal_event,
|
||||
"Expected some event for deleted testfile.txt but got none within 5s"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same test but with MULTIPLE NonRecursive watches (base + src + .git)
|
||||
/// to match what fff actually does. Each watch() call stops/restarts the FSEvents stream.
|
||||
#[test]
|
||||
fn raw_notify_multi_nonrecursive_detects_deletion() {
|
||||
let (dir, _tmp) = setup_temp_git_repo();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let config = Config::default()
|
||||
.with_follow_symlinks(false)
|
||||
.with_event_kinds(EventKindMask::CORE);
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: notify::Result<Event>| {
|
||||
if let Ok(ev) = res {
|
||||
let _ = tx.send(ev);
|
||||
}
|
||||
},
|
||||
config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Watch multiple directories NonRecursively — EACH call restarts the FSEvents stream
|
||||
watcher
|
||||
.watch(dir.as_path(), RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
watcher
|
||||
.watch(&dir.join("src"), RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
watcher
|
||||
.watch(&dir.join(".git"), RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
|
||||
// Let the watcher stabilize
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
// Create a file in root dir
|
||||
let file_path = dir.join("testfile.txt");
|
||||
fs::write(&file_path, "content\n").unwrap();
|
||||
|
||||
// Wait for create event
|
||||
let mut got_create = false;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ev) => {
|
||||
eprintln!(" [multi-create] event: {:?} paths={:?}", ev.kind, ev.paths);
|
||||
if matches!(ev.kind, EventKind::Create(_)) && ev.paths.contains(&file_path) {
|
||||
got_create = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
got_create,
|
||||
"Expected Create event for testfile.txt with multi-watch"
|
||||
);
|
||||
|
||||
// Drain
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
// Delete the file
|
||||
fs::remove_file(&file_path).unwrap();
|
||||
eprintln!(" File deleted: {}", file_path.display());
|
||||
|
||||
// Wait for any event related to the deletion
|
||||
let mut got_removal_event = false;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ev) => {
|
||||
eprintln!(" [multi-delete] event: {:?} paths={:?}", ev.kind, ev.paths);
|
||||
if ev.paths.contains(&file_path) {
|
||||
got_removal_event = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
got_removal_event,
|
||||
"Expected some event for deleted testfile.txt with multi-watch but got none within 5s"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test with debouncer (matching exactly what fff uses)
|
||||
#[test]
|
||||
fn debounced_nonrecursive_detects_deletion() {
|
||||
use notify_debouncer_full::{DebounceEventResult, NoCache, new_debouncer_opt};
|
||||
|
||||
let (dir, _tmp) = setup_temp_git_repo();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let config = Config::default()
|
||||
.with_follow_symlinks(false)
|
||||
.with_event_kinds(EventKindMask::CORE);
|
||||
|
||||
let mut debouncer: notify_debouncer_full::Debouncer<RecommendedWatcher, NoCache> =
|
||||
new_debouncer_opt(
|
||||
Duration::from_millis(250),
|
||||
Some(Duration::from_millis(125)),
|
||||
move |result: DebounceEventResult| {
|
||||
if let Ok(events) = result {
|
||||
for ev in events {
|
||||
eprintln!(
|
||||
" [debounced-cb] kind={:?} paths={:?}",
|
||||
ev.event.kind, ev.event.paths
|
||||
);
|
||||
let _ = tx.send(ev);
|
||||
}
|
||||
}
|
||||
},
|
||||
NoCache::new(),
|
||||
config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Watch like fff does
|
||||
debouncer
|
||||
.watch(dir.as_path(), RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
debouncer
|
||||
.watch(&dir.join("src"), RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
debouncer
|
||||
.watch(&dir.join(".git"), RecursiveMode::NonRecursive)
|
||||
.unwrap();
|
||||
|
||||
// Longer stabilization — each watch() restarts the FSEvents stream
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
// Create file
|
||||
let file_path = dir.join("testfile.txt");
|
||||
fs::write(&file_path, "content\n").unwrap();
|
||||
|
||||
let mut got_create = false;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ev) => {
|
||||
if ev.event.paths.contains(&file_path) {
|
||||
got_create = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
assert!(got_create, "Expected Create event via debouncer");
|
||||
|
||||
// Wait for debounce to fully flush
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
// Delete
|
||||
fs::remove_file(&file_path).unwrap();
|
||||
eprintln!(" File deleted: {}", file_path.display());
|
||||
|
||||
// Wait for ANY event for this path
|
||||
let mut got_event = false;
|
||||
let mut event_kind = String::new();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ev) => {
|
||||
if ev.event.paths.contains(&file_path) {
|
||||
event_kind = format!("{:?}", ev.event.kind);
|
||||
got_event = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
got_event,
|
||||
"Expected some event for deleted testfile.txt via debouncer but got none within 5s"
|
||||
);
|
||||
eprintln!(" Got event kind: {}", event_kind);
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
//! Randomized file-system mutation stress test.
|
||||
//!
|
||||
//! Seeds a directory with ~40 files across diverse content domains, builds the
|
||||
//! picker + bigram index, then runs 20 rounds of randomized create / edit /
|
||||
//! delete / rename / read-only operations. After every round the test verifies
|
||||
//! that plain-text grep, regex grep, and fuzzy file search all return correct
|
||||
//! results for every live and dead file.
|
||||
//!
|
||||
//! Uses a seeded RNG (`SmallRng::seed_from_u64`) for deterministic
|
||||
//! reproduction.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker, FuzzySearchOptions};
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{
|
||||
FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
|
||||
const DOMAINS: &[&str] = &[
|
||||
r#"
|
||||
use std::net::{TcpStream, SocketAddr};
|
||||
fn establish_connection(addr: SocketAddr) -> Result<TcpStream, std::io::Error> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(stream)
|
||||
}
|
||||
fn parse_http_header(raw: &[u8]) -> Option<(&str, &str)> {
|
||||
let line = std::str::from_utf8(raw).ok()?;
|
||||
let (key, val) = line.split_once(':')?;
|
||||
Some((key.trim(), val.trim()))
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use sqlx::{PgPool, Row};
|
||||
async fn query_users(pool: &PgPool, limit: i64) -> Vec<String> {
|
||||
sqlx::query("SELECT name FROM users ORDER BY created_at DESC LIMIT $1")
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| row.get("name"))
|
||||
.collect()
|
||||
}
|
||||
async fn insert_record(pool: &PgPool, name: &str) -> i64 {
|
||||
sqlx::query_scalar("INSERT INTO records (name) VALUES ($1) RETURNING id")
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
fn verify_jwt_token(token: &str, secret: &[u8]) -> Result<Claims, AuthError> {
|
||||
let parts: Vec<&str> = token.splitn(3, '.').collect();
|
||||
if parts.len() != 3 { return Err(AuthError::MalformedToken); }
|
||||
let payload = base64_decode(parts[1])?;
|
||||
let signature = hmac_sha256(secret, &format!("{}.{}", parts[0], parts[1]));
|
||||
if signature != base64_decode(parts[2])? { return Err(AuthError::InvalidSignature); }
|
||||
serde_json::from_slice(&payload).map_err(AuthError::Deserialize)
|
||||
}
|
||||
fn hash_password(password: &str, salt: &[u8]) -> Vec<u8> {
|
||||
argon2::hash_encoded(password.as_bytes(), salt, &argon2::Config::default())
|
||||
.unwrap().into_bytes()
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
struct Renderer { framebuffer: Vec<u32>, width: usize, height: usize }
|
||||
impl Renderer {
|
||||
fn clear(&mut self, color: u32) { self.framebuffer.fill(color); }
|
||||
fn draw_pixel(&mut self, x: usize, y: usize, color: u32) {
|
||||
if x < self.width && y < self.height {
|
||||
self.framebuffer[y * self.width + x] = color;
|
||||
}
|
||||
}
|
||||
fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: u32) {
|
||||
let dx = (x1 - x0).abs(); let dy = -(y1 - y0).abs();
|
||||
let mut err = dx + dy;
|
||||
let (mut cx, mut cy) = (x0, y0);
|
||||
loop {
|
||||
self.draw_pixel(cx as usize, cy as usize, color);
|
||||
if cx == x1 && cy == y1 { break; }
|
||||
let e2 = 2 * err;
|
||||
if e2 >= dy { err += dy; cx += if x0 < x1 { 1 } else { -1 }; }
|
||||
if e2 <= dx { err += dx; cy += if y0 < y1 { 1 } else { -1 }; }
|
||||
}
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use serde::{Serialize, Deserialize};
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ConfigFile { log_level: String, max_retries: u32, timeout_ms: u64 }
|
||||
fn load_config(path: &std::path::Path) -> Result<ConfigFile, Box<dyn std::error::Error>> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
let config: ConfigFile = toml::from_str(&contents)?;
|
||||
Ok(config)
|
||||
}
|
||||
fn merge_configs(base: ConfigFile, overlay: ConfigFile) -> ConfigFile {
|
||||
ConfigFile {
|
||||
log_level: if overlay.log_level.is_empty() { base.log_level } else { overlay.log_level },
|
||||
max_retries: overlay.max_retries.max(base.max_retries),
|
||||
timeout_ms: overlay.timeout_ms.max(base.timeout_ms),
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
struct PhysicsBody { position: [f64; 3], velocity: [f64; 3], mass: f64 }
|
||||
fn apply_gravity(bodies: &mut [PhysicsBody], dt: f64) {
|
||||
let gravity_constant = 6.674e-11;
|
||||
let len = bodies.len();
|
||||
let mut forces = vec![[0.0f64; 3]; len];
|
||||
for i in 0..len {
|
||||
for j in (i+1)..len {
|
||||
let dx = bodies[j].position[0] - bodies[i].position[0];
|
||||
let dy = bodies[j].position[1] - bodies[i].position[1];
|
||||
let dz = bodies[j].position[2] - bodies[i].position[2];
|
||||
let dist_sq = dx*dx + dy*dy + dz*dz;
|
||||
let force_mag = gravity_constant * bodies[i].mass * bodies[j].mass / dist_sq;
|
||||
let dist = dist_sq.sqrt();
|
||||
for k in 0..3 {
|
||||
let f = force_mag * [dx, dy, dz][k] / dist;
|
||||
forces[i][k] += f; forces[j][k] -= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (body, force) in bodies.iter_mut().zip(forces.iter()) {
|
||||
for k in 0..3 {
|
||||
body.velocity[k] += force[k] / body.mass * dt;
|
||||
body.position[k] += body.velocity[k] * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use std::collections::BTreeMap;
|
||||
struct CacheEntry<V> { value: V, frequency: u64, last_access: u64 }
|
||||
struct LFUCache<K: Ord, V> { map: BTreeMap<K, CacheEntry<V>>, capacity: usize, clock: u64 }
|
||||
impl<K: Ord, V> LFUCache<K, V> {
|
||||
fn new(capacity: usize) -> Self { Self { map: BTreeMap::new(), capacity, clock: 0 } }
|
||||
fn get(&mut self, key: &K) -> Option<&V> {
|
||||
self.clock += 1;
|
||||
let entry = self.map.get_mut(key)?;
|
||||
entry.frequency += 1;
|
||||
entry.last_access = self.clock;
|
||||
Some(&entry.value)
|
||||
}
|
||||
fn insert(&mut self, key: K, value: V) {
|
||||
self.clock += 1;
|
||||
if self.map.len() >= self.capacity { self.evict(); }
|
||||
self.map.insert(key, CacheEntry { value, frequency: 1, last_access: self.clock });
|
||||
}
|
||||
fn evict(&mut self) {
|
||||
if let Some(victim) = self.map.keys().min_by_key(|k| {
|
||||
let e = &self.map[*k]; (e.frequency, e.last_access)
|
||||
}).cloned() { self.map.remove(&victim); }
|
||||
}
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
fn tokenize_expression(input: &str) -> Vec<Token> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
match ch {
|
||||
'0'..='9' => {
|
||||
let mut num = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_digit() || d == '.' { num.push(d); chars.next(); }
|
||||
else { break; }
|
||||
}
|
||||
tokens.push(Token::Number(num.parse().unwrap()));
|
||||
}
|
||||
'+' => { tokens.push(Token::Plus); chars.next(); }
|
||||
'-' => { tokens.push(Token::Minus); chars.next(); }
|
||||
'*' => { tokens.push(Token::Star); chars.next(); }
|
||||
'/' => { tokens.push(Token::Slash); chars.next(); }
|
||||
'(' => { tokens.push(Token::LParen); chars.next(); }
|
||||
')' => { tokens.push(Token::RParen); chars.next(); }
|
||||
_ if ch.is_whitespace() => { chars.next(); }
|
||||
_ => { chars.next(); }
|
||||
}
|
||||
}
|
||||
tokens
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
fn parallel_map<T: Send + 'static, R: Send + 'static>(
|
||||
items: Vec<T>, num_threads: usize, f: fn(T) -> R
|
||||
) -> Vec<R> {
|
||||
let chunk_size = (items.len() + num_threads - 1) / num_threads;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut handles = Vec::new();
|
||||
for (chunk_idx, chunk) in items.into_iter().collect::<Vec<_>>()
|
||||
.chunks(chunk_size).enumerate()
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let chunk = chunk.to_vec();
|
||||
handles.push(thread::spawn(move || {
|
||||
for (i, item) in chunk.into_iter().enumerate() {
|
||||
tx.send((chunk_idx * chunk_size + i, f(item))).unwrap();
|
||||
}
|
||||
}));
|
||||
}
|
||||
drop(tx);
|
||||
let mut results: Vec<Option<R>> = vec![None; handles.len() * chunk_size];
|
||||
for (idx, result) in rx { if idx < results.len() { results[idx] = Some(result); } }
|
||||
for h in handles { h.join().unwrap(); }
|
||||
results.into_iter().flatten().collect()
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
struct Compressor { window: Vec<u8>, window_size: usize }
|
||||
impl Compressor {
|
||||
fn new(window_size: usize) -> Self {
|
||||
Self { window: Vec::with_capacity(window_size), window_size }
|
||||
}
|
||||
fn find_longest_match(&self, data: &[u8], pos: usize) -> (usize, usize) {
|
||||
let mut best_offset = 0; let mut best_length = 0;
|
||||
let start = pos.saturating_sub(self.window_size);
|
||||
for offset in start..pos {
|
||||
let mut length = 0;
|
||||
while pos + length < data.len()
|
||||
&& data[offset + length] == data[pos + length]
|
||||
&& length < 258
|
||||
{ length += 1; }
|
||||
if length > best_length { best_offset = pos - offset; best_length = length; }
|
||||
}
|
||||
(best_offset, best_length)
|
||||
}
|
||||
fn compress(&mut self, data: &[u8]) -> Vec<u8> {
|
||||
let mut output = Vec::new();
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
let (offset, length) = self.find_longest_match(data, pos);
|
||||
if length >= 3 {
|
||||
output.push(1); output.extend_from_slice(&(offset as u16).to_le_bytes());
|
||||
output.push(length as u8); pos += length;
|
||||
} else { output.push(0); output.push(data[pos]); pos += 1; }
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
"#,
|
||||
];
|
||||
|
||||
struct FileState {
|
||||
name: String,
|
||||
token: String,
|
||||
#[allow(dead_code)]
|
||||
is_base: bool,
|
||||
/// Epoch second when this file was last written (used to detect same-second
|
||||
/// re-edits that wouldn't bump mtime and thus wouldn't invalidate the mmap).
|
||||
last_write_sec: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_file_operations_stress() {
|
||||
const SEED: u64 = 0xDEAD_BEEF_CAFE_1234;
|
||||
const INITIAL_FILE_COUNT: usize = 40;
|
||||
const NUM_ROUNDS: usize = 20;
|
||||
|
||||
let mut rng = SmallRng::seed_from_u64(SEED);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
// Timing accumulators.
|
||||
let mut t_sleep = Duration::ZERO;
|
||||
let mut t_git = Duration::ZERO;
|
||||
let mut t_bigram_wait = Duration::ZERO;
|
||||
let mut t_grep_plain = Duration::ZERO;
|
||||
let mut t_grep_regex = Duration::ZERO;
|
||||
let mut t_fuzzy = Duration::ZERO;
|
||||
let mut t_dead_check = Duration::ZERO;
|
||||
let grep_plain_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let grep_regex_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let fuzzy_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let dead_calls = std::sync::atomic::AtomicUsize::new(0);
|
||||
let test_start = std::time::Instant::now();
|
||||
|
||||
let mut live_files: Vec<FileState> = Vec::with_capacity(INITIAL_FILE_COUNT + NUM_ROUNDS);
|
||||
let mut dead_tokens: Vec<String> = Vec::new();
|
||||
let mut next_file_id: usize = 0;
|
||||
|
||||
for i in 0..INITIAL_FILE_COUNT {
|
||||
let name = format!("seed_{i:04}.rs");
|
||||
let token = format!("FUZZ_SEED_{i:04}");
|
||||
write_diverse_file(base, &name, &token, i);
|
||||
live_files.push(FileState {
|
||||
name,
|
||||
token,
|
||||
is_base: true,
|
||||
last_write_sec: 0, // set before index build, doesn't matter
|
||||
});
|
||||
next_file_id += 1;
|
||||
}
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
git_init_and_commit(base);
|
||||
t_git += t0.elapsed();
|
||||
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
SharedFrecency::noop(),
|
||||
FilePickerOptions {
|
||||
watch: false, // we do not need the backgrodun monitor
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
wait_for_bigram(&shared_picker);
|
||||
t_bigram_wait += t0.elapsed();
|
||||
|
||||
// Sanity: all initial tokens findable via plain grep.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
for fs in &live_files {
|
||||
assert!(
|
||||
grep_plain_count(picker, &fs.token) >= 1,
|
||||
"initial sanity: plain grep should find token {} in {}",
|
||||
fs.token,
|
||||
fs.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep so mtime advances past the scan snapshot timestamp.
|
||||
let t0 = std::time::Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
t_sleep += t0.elapsed();
|
||||
|
||||
let mut op_counter: usize = 0;
|
||||
|
||||
for round in 0..NUM_ROUNDS {
|
||||
let roll: u32 = rng.next_u32() % 100;
|
||||
|
||||
if roll < 40 && !live_files.is_empty() {
|
||||
// ── EDIT existing file (40%) ──
|
||||
let idx = rng.next_u32() as usize % live_files.len();
|
||||
|
||||
// on_create_or_modify uses mtime (seconds granularity) to decide
|
||||
// whether to invalidate the mmap cache. If we re-edit a file in
|
||||
// the same second it was last written, the mtime won't change and
|
||||
// the stale cached content will be returned. Sleep to advance mtime.
|
||||
let now_sec = epoch_secs();
|
||||
if live_files[idx].last_write_sec >= now_sec {
|
||||
let t0 = std::time::Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
t_sleep += t0.elapsed();
|
||||
}
|
||||
|
||||
let old_token = live_files[idx].token.clone();
|
||||
let new_token = format!("FUZZ_{round:02}_{op_counter:04}");
|
||||
let name = &live_files[idx].name;
|
||||
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
|
||||
write_diverse_file_with_domain(base, name, &new_token, domain_idx);
|
||||
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(
|
||||
picker.on_create_or_modify(base.join(name)).is_some(),
|
||||
"round {round}: on_create_or_modify({name}) should succeed for edit"
|
||||
);
|
||||
}
|
||||
|
||||
dead_tokens.push(old_token);
|
||||
live_files[idx].token = new_token;
|
||||
live_files[idx].last_write_sec = epoch_secs();
|
||||
op_counter += 1;
|
||||
} else if roll < 60 {
|
||||
// ── CREATE new file (20%) ──
|
||||
let name = format!("created_{next_file_id:04}.rs");
|
||||
let token = format!("FUZZ_{round:02}_{op_counter:04}");
|
||||
let domain_idx = rng.next_u32() as usize % DOMAINS.len();
|
||||
write_diverse_file_with_domain(base, &name, &token, domain_idx);
|
||||
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(
|
||||
picker.on_create_or_modify(base.join(&name)).is_some(),
|
||||
"round {round}: on_create_or_modify({name}) should succeed for create"
|
||||
);
|
||||
}
|
||||
|
||||
live_files.push(FileState {
|
||||
name,
|
||||
token,
|
||||
is_base: false,
|
||||
last_write_sec: epoch_secs(),
|
||||
});
|
||||
next_file_id += 1;
|
||||
op_counter += 1;
|
||||
} else if roll < 75 && !live_files.is_empty() {
|
||||
// ── DELETE existing file (15%) ──
|
||||
let idx = rng.next_u32() as usize % live_files.len();
|
||||
let removed = live_files.swap_remove(idx);
|
||||
let path = base.join(&removed.name);
|
||||
fs::remove_file(&path).unwrap();
|
||||
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(
|
||||
picker.remove_file_by_path(&path),
|
||||
"round {round}: remove_file_by_path({}) should succeed",
|
||||
removed.name
|
||||
);
|
||||
}
|
||||
|
||||
dead_tokens.push(removed.token);
|
||||
op_counter += 1;
|
||||
} else if roll < 85 && !live_files.is_empty() {
|
||||
// ── RENAME file (10%) ──
|
||||
let idx = rng.next_u32() as usize % live_files.len();
|
||||
let old_name = live_files[idx].name.clone();
|
||||
let old_path = base.join(&old_name);
|
||||
let content = fs::read_to_string(&old_path).unwrap();
|
||||
|
||||
// Remove old file from disk + picker.
|
||||
fs::remove_file(&old_path).unwrap();
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
picker.remove_file_by_path(&old_path);
|
||||
}
|
||||
|
||||
// Create new file with same content but different name.
|
||||
let new_name = format!("renamed_{next_file_id:04}.rs");
|
||||
fs::write(base.join(&new_name), &content).unwrap();
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(
|
||||
picker.on_create_or_modify(base.join(&new_name)).is_some(),
|
||||
"round {round}: on_create_or_modify({new_name}) should succeed for rename"
|
||||
);
|
||||
}
|
||||
|
||||
live_files[idx].name = new_name;
|
||||
live_files[idx].is_base = false;
|
||||
live_files[idx].last_write_sec = epoch_secs();
|
||||
next_file_id += 1;
|
||||
op_counter += 1;
|
||||
}
|
||||
// else: no-op / read-only (15%) — just run verification below.
|
||||
|
||||
// ── VERIFY after every round ──
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
for fs in &live_files {
|
||||
// Plain text grep: every live token must be found.
|
||||
let t0 = std::time::Instant::now();
|
||||
let plain_count = grep_plain_count(picker, &fs.token);
|
||||
t_grep_plain += t0.elapsed();
|
||||
grep_plain_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
plain_count >= 1,
|
||||
"round {round}: plain grep should find live token {} in {} (got {plain_count})",
|
||||
fs.token,
|
||||
fs.name
|
||||
);
|
||||
|
||||
// Regex grep: search with `{first5}.*{last5}` pattern.
|
||||
let regex_pattern = build_regex_pattern(&fs.token);
|
||||
let t0 = std::time::Instant::now();
|
||||
let regex_count = grep_regex_count(picker, ®ex_pattern);
|
||||
t_grep_regex += t0.elapsed();
|
||||
grep_regex_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
regex_count >= 1,
|
||||
"round {round}: regex grep '{}' should find live token {} in {} (got {regex_count})",
|
||||
regex_pattern,
|
||||
fs.token,
|
||||
fs.name
|
||||
);
|
||||
|
||||
// Fuzzy file search: every live file must be findable by name.
|
||||
let stem = extract_stem(&fs.name);
|
||||
let t0 = std::time::Instant::now();
|
||||
let fuzzy_results = fuzzy_search_paths(picker, &stem);
|
||||
t_fuzzy += t0.elapsed();
|
||||
fuzzy_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
fuzzy_results.iter().any(|p| p.contains(&fs.name)),
|
||||
"round {round}: fuzzy search '{}' should find file {} in results: {:?}",
|
||||
stem,
|
||||
fs.name,
|
||||
fuzzy_results
|
||||
);
|
||||
}
|
||||
|
||||
// Dead tokens must return 0 grep results.
|
||||
for dead in &dead_tokens {
|
||||
let t0 = std::time::Instant::now();
|
||||
let count = grep_plain_count(picker, dead);
|
||||
t_dead_check += t0.elapsed();
|
||||
dead_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(
|
||||
count, 0,
|
||||
"round {round}: dead token {dead} should NOT be findable (got {count})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = test_start.elapsed();
|
||||
let t_overhead = t_sleep + t_bigram_wait + t_git;
|
||||
let t_search = t_grep_plain + t_grep_regex + t_fuzzy + t_dead_check;
|
||||
let t_mutations = total.saturating_sub(t_overhead + t_search);
|
||||
let n_grep_plain = grep_plain_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let n_grep_regex = grep_regex_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let n_fuzzy = fuzzy_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let n_dead = dead_calls.load(std::sync::atomic::Ordering::Relaxed);
|
||||
eprintln!("\n╔══════════════════════════════════════════════════════╗");
|
||||
eprintln!("║ Fuzz Test Performance Breakdown ║");
|
||||
eprintln!("╠══════════════════════════════════════════════════════╣");
|
||||
eprintln!(
|
||||
"║ Total wall time: {:>8.1}ms ║",
|
||||
total.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!("║ ── Overhead ─────────────────────────────────────── ║");
|
||||
eprintln!(
|
||||
"║ Sleep (mtime waits): {:>8.1}ms ║",
|
||||
t_sleep.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!(
|
||||
"║ Git init+commit: {:>8.1}ms ║",
|
||||
t_git.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!(
|
||||
"║ Bigram index build+scan: {:>8.1}ms ║",
|
||||
t_bigram_wait.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!(
|
||||
"║ ── Search ({:>3} live files, {:>3} dead tokens) ────── ║",
|
||||
live_files.len(),
|
||||
dead_tokens.len()
|
||||
);
|
||||
eprintln!(
|
||||
"║ Plain grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_grep_plain,
|
||||
t_grep_plain.as_secs_f64() * 1000.0,
|
||||
t_grep_plain.as_secs_f64() * 1_000_000.0 / n_grep_plain.max(1) as f64
|
||||
);
|
||||
eprintln!(
|
||||
"║ Regex grep: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_grep_regex,
|
||||
t_grep_regex.as_secs_f64() * 1000.0,
|
||||
t_grep_regex.as_secs_f64() * 1_000_000.0 / n_grep_regex.max(1) as f64
|
||||
);
|
||||
eprintln!(
|
||||
"║ Fuzzy find: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_fuzzy,
|
||||
t_fuzzy.as_secs_f64() * 1000.0,
|
||||
t_fuzzy.as_secs_f64() * 1_000_000.0 / n_fuzzy.max(1) as f64
|
||||
);
|
||||
eprintln!(
|
||||
"║ Dead checks: {:>4} calls {:>8.1}ms ({:>6.1}µs/call) ║",
|
||||
n_dead,
|
||||
t_dead_check.as_secs_f64() * 1000.0,
|
||||
t_dead_check.as_secs_f64() * 1_000_000.0 / n_dead.max(1) as f64
|
||||
);
|
||||
eprintln!("║ ── Other ────────────────────────────────────────── ║");
|
||||
eprintln!(
|
||||
"║ Mutations + FS I/O: {:>8.1}ms ║",
|
||||
t_mutations.as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!("╚══════════════════════════════════════════════════════╝");
|
||||
}
|
||||
|
||||
fn write_diverse_file(dir: &Path, name: &str, token: &str, index: usize) {
|
||||
let domain_idx = index % DOMAINS.len();
|
||||
write_diverse_file_with_domain(dir, name, token, domain_idx);
|
||||
}
|
||||
|
||||
fn write_diverse_file_with_domain(dir: &Path, name: &str, token: &str, domain_idx: usize) {
|
||||
let domain = DOMAINS[domain_idx % DOMAINS.len()];
|
||||
let content = format!(
|
||||
"// File: {name}\n\
|
||||
// Domain content for bigram diversity\n\
|
||||
{domain}\n\
|
||||
// === Unique searchable token below ===\n\
|
||||
const MARKER: &str = \"{token}\";\n\
|
||||
fn marker_function_{token}() {{ println!(\"{token}\"); }}\n"
|
||||
);
|
||||
|
||||
if let Some(parent) = PathBuf::from(name).parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(dir.join(parent)).unwrap();
|
||||
}
|
||||
}
|
||||
fs::write(dir.join(name), content).unwrap();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Search helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn grep_plain_opts() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 500,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_regex_opts() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
mode: GrepMode::Regex,
|
||||
..grep_plain_opts()
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
|
||||
let parsed = parse_grep_query(query);
|
||||
picker.grep(&parsed, &grep_plain_opts()).matches.len()
|
||||
}
|
||||
|
||||
fn grep_regex_count(picker: &FilePicker, regex_query: &str) -> usize {
|
||||
let parsed = parse_grep_query(regex_query);
|
||||
picker.grep(&parsed, &grep_regex_opts()).matches.len()
|
||||
}
|
||||
|
||||
/// Build a regex pattern from a token: `{first5}.*{last5}`.
|
||||
/// For tokens shorter than 10 chars, just use the literal (escaped).
|
||||
fn build_regex_pattern(token: &str) -> String {
|
||||
if token.len() >= 10 {
|
||||
let first5 = &token[..5];
|
||||
let last5 = &token[token.len() - 5..];
|
||||
format!("{}.*{}", regex_escape(first5), regex_escape(last5))
|
||||
} else {
|
||||
regex_escape(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape regex metacharacters in a string.
|
||||
fn regex_escape(s: &str) -> String {
|
||||
let mut escaped = String::with_capacity(s.len() + 4);
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' | '^' | '$' | '|' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Extract a fuzzy-searchable stem from a filename.
|
||||
/// Strips the extension and any leading path components, keeping the bare name.
|
||||
fn extract_stem(name: &str) -> String {
|
||||
let p = PathBuf::from(name);
|
||||
p.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 1,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wait_for_bigram(shared_picker: &SharedFilePicker) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn git_run(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test.com")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test.com")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn git_init_and_commit(dir: &Path) {
|
||||
git_run(dir, &["init"]);
|
||||
git_run(dir, &["add", "-A"]);
|
||||
git_run(dir, &["commit", "-m", "initial"]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
//! Reproduces the deadlock/hang caused by LMDB writer mutex contention.
|
||||
//!
|
||||
//! When another process holds the LMDB writer mutex (via a long-running write
|
||||
//! transaction or because it crashed without releasing it), any call to
|
||||
//! `write_txn()` blocks indefinitely — including on the neovim main thread
|
||||
//! during `QueryTracker::open()` or frecency `track_access()`.
|
||||
//!
|
||||
//! In production this manifests as neovim hanging on startup:
|
||||
//! require('fff.core').ensure_initialized()
|
||||
//! → init_db() → QueryTracker::open() → write_txn() → HANGS
|
||||
//!
|
||||
//! Or during normal use when BufEnter fires:
|
||||
//! track_access → frecency.track_access() → write_txn() → HANGS
|
||||
//!
|
||||
//! Reproduction: fork a child process that holds the LMDB write lock
|
||||
//! indefinitely, then attempt to use the same database from the parent.
|
||||
//! The parent's `write_txn()` blocks on the cross-process writer mutex.
|
||||
//!
|
||||
//! This test confirms that the current code has NO timeout or fallback when the
|
||||
//! LMDB writer mutex is unavailable — making it vulnerable to indefinite hangs
|
||||
//! whenever another process (fff-mcp, another neovim, or a crashed instance)
|
||||
//! holds or has stuck the mutex.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fff_search::frecency::FrecencyTracker;
|
||||
use fff_search::query_tracker::QueryTracker;
|
||||
|
||||
/// Returns whether `f` completes within `timeout`.
|
||||
fn completes_within(
|
||||
label: &'static str,
|
||||
timeout: Duration,
|
||||
f: impl FnOnce() + Send + 'static,
|
||||
) -> bool {
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
let _worker = std::thread::Builder::new()
|
||||
.name(format!("deadlock-repro-{label}"))
|
||||
.spawn(move || {
|
||||
f();
|
||||
let _ = tx.send(());
|
||||
})
|
||||
.expect("spawn worker");
|
||||
|
||||
rx.recv_timeout(timeout).is_ok()
|
||||
}
|
||||
|
||||
/// Fork a child that opens the LMDB env and holds a write transaction
|
||||
/// indefinitely (simulating a stuck/long-running process). Returns the
|
||||
/// child PID so the parent can kill it during cleanup.
|
||||
fn fork_child_holding_write_lock(db_path: &Path) -> libc::pid_t {
|
||||
let db_path_str = db_path.to_str().unwrap().to_owned();
|
||||
|
||||
let mut pipe_fds: [libc::c_int; 2] = [0; 2];
|
||||
assert_eq!(unsafe { libc::pipe(pipe_fds.as_mut_ptr()) }, 0);
|
||||
let read_fd = pipe_fds[0];
|
||||
let write_fd = pipe_fds[1];
|
||||
|
||||
let child_pid = unsafe { libc::fork() };
|
||||
match child_pid {
|
||||
-1 => panic!("fork() failed: {}", std::io::Error::last_os_error()),
|
||||
0 => {
|
||||
// === CHILD PROCESS ===
|
||||
unsafe { libc::close(read_fd) };
|
||||
|
||||
let env = unsafe {
|
||||
let mut opts = heed::EnvOpenOptions::new();
|
||||
opts.map_size(10 * 1024 * 1024);
|
||||
opts.open(Path::new(&db_path_str)).expect("child: open env")
|
||||
};
|
||||
|
||||
// Acquire the cross-process writer mutex via write_txn
|
||||
let _wtxn = env.write_txn().expect("child: write_txn");
|
||||
|
||||
// Signal parent that the lock is held
|
||||
unsafe { libc::write(write_fd, b"R".as_ptr() as *const libc::c_void, 1) };
|
||||
|
||||
// Hold the lock forever — parent will eventually kill us
|
||||
loop {
|
||||
unsafe { libc::pause() };
|
||||
}
|
||||
}
|
||||
pid => {
|
||||
// === PARENT PROCESS ===
|
||||
unsafe { libc::close(write_fd) };
|
||||
|
||||
// Wait for child to confirm it holds the write lock
|
||||
let mut buf = [0u8; 1];
|
||||
let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
|
||||
assert_eq!(n, 1, "child didn't signal readiness");
|
||||
assert_eq!(buf[0], b'R');
|
||||
unsafe { libc::close(read_fd) };
|
||||
|
||||
pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill and reap the child process.
|
||||
fn kill_child(pid: libc::pid_t) {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
let mut status: libc::c_int = 0;
|
||||
libc::waitpid(pid, &mut status, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify QueryTracker works correctly after close+reopen — the
|
||||
/// open_database_safe path must find existing named databases via read txn.
|
||||
#[test]
|
||||
fn lmdb_reopen_finds_existing_databases() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("lmdb_reopen");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
// First open: creates the databases via write_txn fallback
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
let project = Path::new("/test/project");
|
||||
let file = Path::new("/test/project/src/main.rs");
|
||||
tracker
|
||||
.track_query_completion("hello", project, file)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Second open: must find existing databases via read txn (no write_txn needed)
|
||||
{
|
||||
let tracker = QueryTracker::open(&db_path).unwrap();
|
||||
let project = Path::new("/test/project");
|
||||
let result = tracker.get_historical_query(project, 0).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("hello".to_string()),
|
||||
"Query history should persist across close/reopen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Env var the test binary checks on startup. When set, the binary skips the
|
||||
/// test harness and runs as a child worker instead. This avoids fork() in a
|
||||
/// multi-threaded parent — which copies mutex/allocator state from threads
|
||||
/// that no longer exist in the child and can deadlock heed/libc.
|
||||
const CHILD_MODE_ENV: &str = "FFF_PARALLEL_OPEN_CLOSE_CHILD";
|
||||
|
||||
/// Runs before the test harness when `CHILD_MODE_ENV` is set. Re-exec of
|
||||
/// the test binary lets us start child workers without forking from a
|
||||
/// multi-threaded parent.
|
||||
#[ctor::ctor]
|
||||
fn maybe_enter_child_mode() {
|
||||
if let Ok(spec) = std::env::var(CHILD_MODE_ENV) {
|
||||
let code = run_child_from_spec(&spec);
|
||||
std::process::exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec format: `db_path|idx|iterations|writer(0|1)`
|
||||
fn run_child_from_spec(spec: &str) -> i32 {
|
||||
let parts: Vec<&str> = spec.split('|').collect();
|
||||
if parts.len() != 4 {
|
||||
return CHILD_BAD_SPEC;
|
||||
}
|
||||
let db_path = parts[0];
|
||||
let idx: usize = match parts[1].parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return CHILD_BAD_SPEC,
|
||||
};
|
||||
let iterations: usize = match parts[2].parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return CHILD_BAD_SPEC,
|
||||
};
|
||||
let is_writer = parts[3] == "1";
|
||||
child_open_close_loop(db_path, iterations, idx, is_writer)
|
||||
}
|
||||
|
||||
const CHILD_OK: i32 = 0;
|
||||
const CHILD_OPEN_FAILED: i32 = 10;
|
||||
const CHILD_READ_FAILED: i32 = 11;
|
||||
const CHILD_WRITE_FAILED: i32 = 12;
|
||||
const CHILD_BAD_SPEC: i32 = 13;
|
||||
|
||||
fn child_open_close_loop(db_path: &str, iterations: usize, idx: usize, is_writer: bool) -> i32 {
|
||||
let project = Path::new("/test/project");
|
||||
for i in 0..iterations {
|
||||
let tracker = match QueryTracker::open(Path::new(db_path)) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("child {idx} iter {i} reader open failed: {e:?}");
|
||||
return CHILD_OPEN_FAILED;
|
||||
}
|
||||
};
|
||||
if let Err(e) = tracker.get_historical_query(project, 0) {
|
||||
eprintln!("child {idx} iter {i} read failed: {e:?}");
|
||||
return CHILD_READ_FAILED;
|
||||
}
|
||||
drop(tracker);
|
||||
|
||||
if is_writer {
|
||||
let mut tracker = match QueryTracker::open(Path::new(db_path)) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("child {idx} iter {i} writer open failed: {e:?}");
|
||||
return CHILD_OPEN_FAILED;
|
||||
}
|
||||
};
|
||||
let file = PathBuf::from(format!("/test/project/c{idx}_{i}.rs"));
|
||||
if let Err(e) = tracker.track_query_completion(&format!("q{idx}_{i}"), project, &file) {
|
||||
eprintln!("child {idx} iter {i} write failed: {e:?}");
|
||||
return CHILD_WRITE_FAILED;
|
||||
}
|
||||
drop(tracker);
|
||||
}
|
||||
}
|
||||
CHILD_OK
|
||||
}
|
||||
|
||||
/// Spawn `n` child processes via `Command::new(current_exe)`. No fork, so
|
||||
/// mutex/allocator state is not inherited. `writers` children also issue
|
||||
/// writes; the rest only read.
|
||||
fn spawn_open_close_children(
|
||||
db_path: &Path,
|
||||
n: usize,
|
||||
writers: usize,
|
||||
ops_per_child: usize,
|
||||
) -> Vec<std::process::Child> {
|
||||
assert!(writers <= n);
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
let db_path_str = db_path.to_str().unwrap().to_owned();
|
||||
|
||||
(0..n)
|
||||
.map(|idx| {
|
||||
let is_writer = idx < writers;
|
||||
let spec = format!(
|
||||
"{db_path_str}|{idx}|{ops_per_child}|{}",
|
||||
if is_writer { 1 } else { 0 }
|
||||
);
|
||||
std::process::Command::new(&exe)
|
||||
.env(CHILD_MODE_ENV, spec)
|
||||
.env_remove("RUST_LOG")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("spawn child")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Wait for every child with a per-call deadline. On timeout, kill and reap
|
||||
/// remaining children and return an Err describing the stuck set.
|
||||
fn wait_all_with_deadline(
|
||||
mut children: Vec<std::process::Child>,
|
||||
deadline: std::time::Instant,
|
||||
) -> Result<(), String> {
|
||||
let mut failures: Vec<(u32, Option<i32>)> = Vec::new();
|
||||
let mut remaining: Vec<std::process::Child> = Vec::new();
|
||||
|
||||
for mut child in children.drain(..) {
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let code = status.code();
|
||||
if code != Some(CHILD_OK) {
|
||||
failures.push((child.id(), code));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
remaining.push(child);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(e) => {
|
||||
failures.push((child.id(), None));
|
||||
let _ = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !remaining.is_empty() {
|
||||
let stuck: Vec<u32> = remaining.iter().map(|c| c.id()).collect();
|
||||
for child in &mut remaining {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
return Err(format!(
|
||||
"deadline exceeded; children still running: {stuck:?}"
|
||||
));
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
return Err(format!("children failed: {failures:?}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Many processes open/close `QueryTracker` against the same DB path.
|
||||
/// Readers only: seeds once, then spawns N reader children.
|
||||
///
|
||||
/// heed 0.22 forbids opening the same env twice *within* one process
|
||||
/// (EnvAlreadyOpened), so cross-process contention is the right axis.
|
||||
#[test]
|
||||
fn query_tracker_many_parallel_open_close_same_path_readers() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("parallel_open_close_readers");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
tracker
|
||||
.track_query_completion(
|
||||
"seed",
|
||||
Path::new("/test/project"),
|
||||
Path::new("/test/project/src/main.rs"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
const N: usize = 8;
|
||||
const OPS: usize = 4;
|
||||
|
||||
let children = spawn_open_close_children(&db_path, N, 0, OPS);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
wait_all_with_deadline(children, deadline).expect("parallel open/close (readers)");
|
||||
|
||||
let tracker = QueryTracker::open(&db_path).expect("post-storm reopen");
|
||||
let project = Path::new("/test/project");
|
||||
let result = tracker.get_historical_query(project, 0).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("seed".to_string()),
|
||||
"Seed query should still be readable after parallel open/close storm"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stronger variant: multiple processes race opens that both read AND write.
|
||||
/// LMDB serializes writers via a cross-process mutex; test that serialization
|
||||
/// makes forward progress and open/close pairs don't deadlock.
|
||||
#[test]
|
||||
fn query_tracker_parallel_open_write_close_same_path() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("parallel_open_write_close");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
tracker
|
||||
.track_query_completion(
|
||||
"seed",
|
||||
Path::new("/test/project"),
|
||||
Path::new("/test/project/src/main.rs"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
const N: usize = 4;
|
||||
const WRITERS: usize = 4;
|
||||
const OPS: usize = 3;
|
||||
|
||||
let children = spawn_open_close_children(&db_path, N, WRITERS, OPS);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
wait_all_with_deadline(children, deadline).expect("parallel open/write/close");
|
||||
|
||||
let tracker = QueryTracker::open(&db_path).expect("post-storm reopen");
|
||||
let project = Path::new("/test/project");
|
||||
let seed = tracker.get_historical_query(project, 0).unwrap();
|
||||
assert!(
|
||||
seed.is_some(),
|
||||
"Env unreadable after parallel open/write/close storm"
|
||||
);
|
||||
}
|
||||
|
||||
/// Within a single process, opening the same env path twice concurrently is
|
||||
/// forbidden by heed — but a strict sequential open→use→drop→open loop must
|
||||
/// succeed every iteration. Regression guard for the reopen path.
|
||||
#[test]
|
||||
fn query_tracker_sequential_reopen_loop_same_path() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("sequential_reopen_loop");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
{
|
||||
let mut tracker = QueryTracker::open(&db_path).unwrap();
|
||||
tracker
|
||||
.track_query_completion(
|
||||
"seed",
|
||||
Path::new("/test/project"),
|
||||
Path::new("/test/project/src/main.rs"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for i in 0..64 {
|
||||
let mut tracker = QueryTracker::open(&db_path).expect("sequential reopen");
|
||||
let project = Path::new("/test/project");
|
||||
let file = PathBuf::from(format!("/test/project/iter_{i}.rs"));
|
||||
tracker
|
||||
.track_query_completion(&format!("iter_{i}"), project, &file)
|
||||
.expect("sequential track");
|
||||
drop(tracker);
|
||||
}
|
||||
|
||||
let tracker = QueryTracker::open(&db_path).expect("final reopen");
|
||||
let project = Path::new("/test/project");
|
||||
assert!(tracker.get_historical_query(project, 0).unwrap().is_some());
|
||||
}
|
||||
|
||||
/// When the frecency DB doesn't exist yet, `FrecencyTracker::open()` falls
|
||||
/// through to `write_txn()` + `create_database()`. This blocks if another
|
||||
/// process holds the writer mutex. This is the first-launch path.
|
||||
///
|
||||
/// NOTE: this test is disabled because heed 0.22 appears to use a
|
||||
/// try-then-create pattern for unnamed databases that doesn't always block.
|
||||
/// The QueryTracker test above (named databases, always needs write_txn)
|
||||
/// reliably demonstrates the same underlying issue.
|
||||
#[test]
|
||||
#[ignore = "heed 0.22 unnamed db creation may not require writer mutex in all cases"]
|
||||
fn frecency_open_blocks_on_fresh_db_when_another_process_holds_write_lock() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("frecency_fresh_deadlock");
|
||||
fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
let env = unsafe {
|
||||
let mut opts = heed::EnvOpenOptions::new();
|
||||
opts.map_size(10 * 1024 * 1024);
|
||||
opts.open(&db_path).unwrap()
|
||||
};
|
||||
drop(env);
|
||||
|
||||
let child_pid = fork_child_holding_write_lock(&db_path);
|
||||
|
||||
let db_path_clone = db_path.clone();
|
||||
let completed = completes_within(
|
||||
"FrecencyTracker::open (fresh db) while writer held",
|
||||
Duration::from_secs(3),
|
||||
move || {
|
||||
let _result = FrecencyTracker::open(&db_path_clone);
|
||||
},
|
||||
);
|
||||
|
||||
kill_child(child_pid);
|
||||
|
||||
assert!(
|
||||
!completed,
|
||||
"Expected FrecencyTracker::open() on a fresh DB to block (writer mutex \
|
||||
held by another process), but it completed."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Integration test: verifying that the background watcher dynamically detects
|
||||
//! newly created directories and picks up files written inside them.
|
||||
//!
|
||||
//! This covers the NonRecursive watching behavior where:
|
||||
//! 1. The watcher starts with watches on directories discovered during the
|
||||
//! initial scan.
|
||||
//! 2. A brand-new subdirectory is created at runtime (after the scan).
|
||||
//! 3. The watcher's event handler detects the directory Create event,
|
||||
//! collects it, and sends it to the owner thread via `watch_tx`.
|
||||
//! 4. The owner thread adds a NonRecursive watch on the new directory and
|
||||
//! does a flat (non-recursive) read_dir to inject files that already
|
||||
//! exist (race-window coverage).
|
||||
//! 5. Files created *after* the watch is established are picked up via
|
||||
//! normal event delivery.
|
||||
//!
|
||||
//! The test uses the real `BackgroundWatcher` (via `watch: true`) and polls
|
||||
//! the picker until the expected files appear or a timeout expires.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{
|
||||
FilePickerOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn git_run(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test.com")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test.com")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn git_init_and_commit(dir: &Path) {
|
||||
git_run(dir, &["init", "-b", "main"]);
|
||||
git_run(dir, &["add", "-A"]);
|
||||
git_run(dir, &["commit", "-m", "initial"]);
|
||||
}
|
||||
|
||||
fn make_watched_picker(base: &Path) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::noop();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
/// Wait for the initial scan + watcher to be fully ready.
|
||||
fn wait_ready(shared_picker: &SharedFilePicker) {
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
"Timed out waiting for initial scan"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(10)),
|
||||
"Timed out waiting for watcher"
|
||||
);
|
||||
}
|
||||
|
||||
/// Poll the picker until `predicate` returns true or timeout expires.
|
||||
/// Returns the elapsed duration if successful, panics on timeout.
|
||||
fn poll_until(
|
||||
shared_picker: &SharedFilePicker,
|
||||
timeout: Duration,
|
||||
description: &str,
|
||||
predicate: impl Fn(&FilePicker) -> bool,
|
||||
) -> Duration {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
if predicate(picker) {
|
||||
return start.elapsed();
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
// One final attempt to give a useful error message.
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let file_count = picker.get_files().len();
|
||||
let paths: Vec<String> = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect();
|
||||
panic!(
|
||||
"Timed out after {:?} waiting for: {}\n\
|
||||
Current file count: {}\n\
|
||||
Current files: {:?}",
|
||||
timeout, description, file_count, paths
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_plain_count(picker: &FilePicker, query: &str) -> usize {
|
||||
let parsed = parse_grep_query(query);
|
||||
let opts = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 500,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
picker.grep(&parsed, &opts).matches.len()
|
||||
}
|
||||
|
||||
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
fff_search::FuzzySearchOptions {
|
||||
max_threads: 1,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Debounce timeout in the watcher is 250ms. Events need to propagate through
|
||||
/// the debouncer, the owner thread park loop (1s), and the picker write lock.
|
||||
/// We use a generous timeout for CI environments.
|
||||
const WATCHER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Create a new directory and immediately write a file inside it.
|
||||
/// The file is written before the watch is registered, so the flat
|
||||
/// inject_existing_files scan in the owner thread must catch it.
|
||||
#[test]
|
||||
fn new_directory_and_file_detected_by_watcher() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
// Seed the repo with some initial files so the scan has something.
|
||||
fs::create_dir_all(base.join("src")).unwrap();
|
||||
fs::write(
|
||||
base.join("src/main.rs"),
|
||||
"fn main() { println!(\"INITIAL_MARKER\"); }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("README.md"), "# Test project\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Sanity: initial file is indexed.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
Duration::from_secs(5),
|
||||
"initial file src/main.rs indexed",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("main.rs"))
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new directory and write a file into it immediately.
|
||||
// The file exists before the watch is registered — inject_existing_files
|
||||
// in the owner thread catches it via a flat read_dir.
|
||||
let new_dir = base.join("src/components");
|
||||
fs::create_dir_all(&new_dir).unwrap();
|
||||
fs::write(
|
||||
new_dir.join("button.rs"),
|
||||
"pub struct Button;\nconst TOKEN: &str = \"NEW_DIR_BUTTON_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Wait for the watcher to detect the new directory + file.
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"file src/components/button.rs in new directory",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("button.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" New directory + file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Also verify via grep that the content is accessible.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds NEW_DIR_BUTTON_TOKEN",
|
||||
|picker| grep_plain_count(picker, "NEW_DIR_BUTTON_TOKEN") >= 1,
|
||||
);
|
||||
|
||||
// And via fuzzy search.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"fuzzy search finds button.rs",
|
||||
|picker| {
|
||||
let results = fuzzy_search_paths(picker, "button");
|
||||
results.iter().any(|p| p.contains("button.rs"))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new directory, then create files AFTER a delay to ensure the
|
||||
/// watch was established on the directory.
|
||||
#[test]
|
||||
fn file_created_after_directory_watch_established() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::create_dir_all(base.join("lib")).unwrap();
|
||||
fs::write(base.join("lib/utils.rs"), "pub fn helper() {}\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create the directory first, wait for the watcher to register it.
|
||||
let new_dir = base.join("lib/models");
|
||||
fs::create_dir(&new_dir).unwrap();
|
||||
|
||||
// Wait long enough for the debouncer to flush + owner thread to add watch.
|
||||
std::thread::sleep(Duration::from_millis(2000));
|
||||
|
||||
// Now write a file into the already-watched directory.
|
||||
fs::write(
|
||||
new_dir.join("user.rs"),
|
||||
"pub struct User { name: String }\nconst TOKEN: &str = \"POST_WATCH_USER_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"file lib/models/user.rs created after directory watch",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("user.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" Post-watch file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Grep sanity.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds POST_WATCH_USER_TOKEN",
|
||||
|picker| grep_plain_count(picker, "POST_WATCH_USER_TOKEN") >= 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a deeply nested directory tree all at once with create_dir_all
|
||||
/// and write a file at the leaf. The watcher must detect the top-level
|
||||
/// directory via the parent's watch, inject_existing_files finds the file
|
||||
/// at the leaf (and intermediate dirs get their own watches from Create
|
||||
/// events on subsequent levels).
|
||||
#[test]
|
||||
fn deeply_nested_new_directories_detected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::write(base.join("root.txt"), "root file\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create each level one at a time, waiting for each watch to register.
|
||||
// inject_existing_files is flat (non-recursive), so deeply nested dirs
|
||||
// need each parent to be watched before we can see files at the leaf.
|
||||
fs::create_dir(base.join("app")).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(2000));
|
||||
|
||||
fs::create_dir(base.join("app/services")).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(2000));
|
||||
|
||||
fs::create_dir(base.join("app/services/auth")).unwrap();
|
||||
// Write the file immediately — inject_existing_files catches it.
|
||||
fs::write(
|
||||
base.join("app/services/auth/jwt.rs"),
|
||||
"pub fn verify_token() {}\nconst TOKEN: &str = \"DEEP_NESTED_JWT_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"deeply nested file app/services/auth/jwt.rs",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("jwt.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" Deeply nested file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Verify content is grepable.
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds DEEP_NESTED_JWT_TOKEN",
|
||||
|picker| grep_plain_count(picker, "DEEP_NESTED_JWT_TOKEN") >= 1,
|
||||
);
|
||||
|
||||
// Now create a sibling at the same depth — the parent (app/services)
|
||||
// is already watched, so this just needs the flat inject.
|
||||
let sibling_dir = base.join("app/services/database");
|
||||
fs::create_dir(&sibling_dir).unwrap();
|
||||
fs::write(
|
||||
sibling_dir.join("pool.rs"),
|
||||
"pub struct ConnectionPool;\nconst TOKEN: &str = \"SIBLING_POOL_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"sibling nested file app/services/database/pool.rs",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("pool.rs"))
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" Sibling nested file detected in {:.0}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"grep finds SIBLING_POOL_TOKEN",
|
||||
|picker| grep_plain_count(picker, "SIBLING_POOL_TOKEN") >= 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new directory and immediately burst-write multiple files.
|
||||
/// inject_existing_files catches all of them in one flat read_dir.
|
||||
#[test]
|
||||
fn burst_file_creation_in_new_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::create_dir_all(base.join("src")).unwrap();
|
||||
fs::write(base.join("src/lib.rs"), "// lib\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create a new directory and immediately write 5 files.
|
||||
let batch_dir = base.join("src/batch");
|
||||
fs::create_dir(&batch_dir).unwrap();
|
||||
|
||||
let file_count = 5;
|
||||
for i in 0..file_count {
|
||||
fs::write(
|
||||
batch_dir.join(format!("item_{i}.rs")),
|
||||
format!("pub struct Item{i};\nconst TOKEN: &str = \"BATCH_ITEM_{i}\";\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Wait for ALL files to appear.
|
||||
let elapsed = poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
&format!("all {file_count} batch files in src/batch/"),
|
||||
|picker| {
|
||||
let batch_count = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
let p = f.relative_path(picker);
|
||||
p.starts_with("src/batch/") || p.starts_with("src\\batch\\")
|
||||
})
|
||||
.count();
|
||||
batch_count >= file_count
|
||||
},
|
||||
);
|
||||
eprintln!(
|
||||
" All {} burst files detected in {:.0}ms",
|
||||
file_count,
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
// Verify each file's content is grepable.
|
||||
for i in 0..file_count {
|
||||
let token = format!("BATCH_ITEM_{i}");
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
&format!("grep finds {token}"),
|
||||
|picker| grep_plain_count(picker, &token) >= 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that gitignored directories created at runtime are NOT watched
|
||||
/// and their files do NOT appear in the index.
|
||||
#[test]
|
||||
fn gitignored_new_directory_excluded() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().canonicalize().unwrap();
|
||||
|
||||
fs::write(base.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
// Ignore the build/ directory.
|
||||
fs::write(base.join(".gitignore"), "build/\n").unwrap();
|
||||
|
||||
git_init_and_commit(&base);
|
||||
|
||||
let (shared_picker, _frecency) = make_watched_picker(&base);
|
||||
wait_ready(&shared_picker);
|
||||
|
||||
// Create a gitignored directory with files.
|
||||
let ignored_dir = base.join("build");
|
||||
fs::create_dir(&ignored_dir).unwrap();
|
||||
fs::write(
|
||||
ignored_dir.join("output.rs"),
|
||||
"const TOKEN: &str = \"IGNORED_BUILD_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Also create a non-ignored directory to confirm the watcher works.
|
||||
let good_dir = base.join("src");
|
||||
fs::create_dir(&good_dir).unwrap();
|
||||
fs::write(
|
||||
good_dir.join("app.rs"),
|
||||
"const TOKEN: &str = \"GOOD_SRC_TOKEN\";\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Wait for the non-ignored file to appear (proves watcher is working).
|
||||
poll_until(
|
||||
&shared_picker,
|
||||
WATCHER_TIMEOUT,
|
||||
"non-ignored file src/app.rs appears",
|
||||
|picker| {
|
||||
picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("app.rs"))
|
||||
},
|
||||
);
|
||||
|
||||
// Give extra time for any straggler events from the ignored dir.
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
|
||||
// The gitignored file must NOT be in the index.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let has_ignored = picker
|
||||
.get_files()
|
||||
.iter()
|
||||
.any(|f| f.relative_path(picker).contains("output.rs"));
|
||||
assert!(
|
||||
!has_ignored,
|
||||
"Gitignored file build/output.rs should NOT be in the index"
|
||||
);
|
||||
|
||||
let grep_count = grep_plain_count(picker, "IGNORED_BUILD_TOKEN");
|
||||
assert_eq!(grep_count, 0, "Gitignored content should NOT be grepable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Regression test for https://github.com/dmtrKovalenko/fff/issues/381
|
||||
//!
|
||||
//! Directory (`PathSegment`) and file-path (`FilePath`) constraints must
|
||||
//! return results on every platform. Indexed paths on Windows use native
|
||||
//! backslash separators, so constraint matching has to accept either `/`
|
||||
//! or `\\` as a path boundary.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::FilePicker;
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{Constraint, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
|
||||
fn create_picker(base: &Path, specs: &[(&str, &str)]) -> FilePicker {
|
||||
for (rel, contents) in specs {
|
||||
let full_path = base.join(rel);
|
||||
if let Some(parent) = full_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&full_path, contents).unwrap();
|
||||
}
|
||||
let mut picker = FilePicker::new(FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
watch: false,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("failed to create FilePicker");
|
||||
picker.collect_files().expect("failed to collect files");
|
||||
picker
|
||||
}
|
||||
|
||||
fn plain_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,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzzy_search_paths(picker: &FilePicker, query: &str) -> Vec<String> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 1,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|f| f.relative_path(picker))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Treat a relative path as a sequence of components regardless of the
|
||||
/// native separator so assertions are portable across Linux, macOS, Windows.
|
||||
fn has_segment(path: &str, segment: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|s| s == segment)
|
||||
}
|
||||
|
||||
/// `grep handleRequest src/` — PathSegment constraint must match a nested
|
||||
/// `src` directory on every platform.
|
||||
#[test]
|
||||
fn grep_with_path_segment_constraint_nested() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/modules/src/services/handler.lua", "handleRequest()\n"),
|
||||
("app/modules/lib/util.lua", "handleRequest()\n"),
|
||||
("src/main.rs", "fn handleRequest() {}\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let parsed = parse_grep_query("handleRequest src/");
|
||||
let result = picker.grep(&parsed, &plain_opts());
|
||||
|
||||
let matched_paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
2,
|
||||
"expected matches in two src/ files, got {matched_paths:?}"
|
||||
);
|
||||
for p in &matched_paths {
|
||||
assert!(
|
||||
has_segment(p, "src"),
|
||||
"every matched file must live under a `src` segment, got {p:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `multi_grep` with a `PathSegment` constraint.
|
||||
#[test]
|
||||
fn multi_grep_with_path_segment_constraint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
(
|
||||
"app/modules/src/controller.lua",
|
||||
"handleRequest\nprocessJob\n",
|
||||
),
|
||||
("app/modules/lib/helper.lua", "handleRequest\n"),
|
||||
("app/src/legacy.lua", "processJob\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let constraints = [Constraint::PathSegment("src")];
|
||||
let patterns = ["handleRequest", "processJob"];
|
||||
let result = picker.multi_grep(&patterns, &constraints, &plain_opts());
|
||||
|
||||
assert!(
|
||||
!result.matches.is_empty(),
|
||||
"multi_grep with `src/` constraint should return matches"
|
||||
);
|
||||
|
||||
let matched_paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
for p in &matched_paths {
|
||||
assert!(
|
||||
has_segment(p, "src"),
|
||||
"every matched file must live under a `src` segment, got {p:?}"
|
||||
);
|
||||
}
|
||||
assert!(matched_paths.iter().any(|p| p.contains("controller.lua")));
|
||||
assert!(matched_paths.iter().any(|p| p.contains("legacy.lua")));
|
||||
}
|
||||
|
||||
/// Fuzzy search (`find_files src/ Controller`) must apply the path-segment
|
||||
/// filter to paths stored during indexing.
|
||||
#[test]
|
||||
fn fuzzy_search_with_path_segment_constraint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/modules/src/services/BaseController.lua", "base\n"),
|
||||
("app/modules/src/services/UserController.lua", "user\n"),
|
||||
("app/modules/lib/BaseController.lua", "lib base\n"),
|
||||
("tests/src/MockController.lua", "mock\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let results = fuzzy_search_paths(&picker, "src/ Controller");
|
||||
|
||||
assert!(
|
||||
!results.is_empty(),
|
||||
"fuzzy search with `src/` constraint should return results"
|
||||
);
|
||||
for p in &results {
|
||||
assert!(
|
||||
has_segment(p, "src"),
|
||||
"every result must live under `src`, got {p:?}"
|
||||
);
|
||||
}
|
||||
assert!(results.iter().any(|p| p.contains("BaseController")));
|
||||
assert!(results.iter().any(|p| p.contains("UserController")));
|
||||
assert!(results.iter().any(|p| p.contains("MockController")));
|
||||
}
|
||||
|
||||
/// `FilePath` suffix constraint must match stored paths even when components
|
||||
/// are separated by the platform-native separator during indexing.
|
||||
#[test]
|
||||
fn multi_grep_with_file_path_suffix_constraint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/modules/src/services/handler.lua", "handleRequest\n"),
|
||||
("other/src/services/handler.lua", "handleRequest\n"),
|
||||
("app/modules/src/services/other.lua", "handleRequest\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let constraints = [Constraint::FilePath("services/handler.lua")];
|
||||
let patterns = ["handleRequest"];
|
||||
let result = picker.multi_grep(&patterns, &constraints, &plain_opts());
|
||||
|
||||
let paths: Vec<String> = result
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.relative_path(&picker))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
paths.len(),
|
||||
2,
|
||||
"expected two matches for services/handler.lua, got {paths:?}"
|
||||
);
|
||||
for p in &paths {
|
||||
let ends_with_services_handler =
|
||||
p.ends_with("services/handler.lua") || p.ends_with("services\\handler.lua");
|
||||
assert!(
|
||||
ends_with_services_handler,
|
||||
"matched path must end with services/handler.lua, got {p:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Glob constraints must match native Windows paths — the picker normalises
|
||||
/// separators when handing paths to the glob matcher.
|
||||
#[test]
|
||||
fn fuzzy_search_with_glob_constraint_matches_on_windows_paths() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let picker = create_picker(
|
||||
tmp.path(),
|
||||
&[
|
||||
("app/src/components/Button.lua", "\n"),
|
||||
("app/src/services/handler.lua", "\n"),
|
||||
("app/lib/components/Ignored.lua", "\n"),
|
||||
],
|
||||
);
|
||||
|
||||
let results = fuzzy_search_paths(&picker, "**/src/**/*.lua");
|
||||
assert!(
|
||||
results.iter().any(|p| p.contains("Button.lua")),
|
||||
"glob `**/src/**/*.lua` must match files below any `src/`, got {results:?}"
|
||||
);
|
||||
assert!(
|
||||
results.iter().any(|p| p.contains("handler.lua")),
|
||||
"glob `**/src/**/*.lua` must match services/handler.lua, got {results:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Regression test: stopping the background watcher while the caller
|
||||
//! holds the [`SharedFilePicker`] write lock must NOT deadlock.
|
||||
//!
|
||||
//! There are two lock-ordering hazards the watcher has to handle:
|
||||
//!
|
||||
//! 1. The debouncer's event thread calls our handler, which wants
|
||||
//! `shared_picker.write()` to apply events. `stop()` used to
|
||||
//! `join()` that thread under the caller's write guard.
|
||||
//!
|
||||
//! 2. The owner thread registers new-directory watches and injects
|
||||
//! their existing files. Previously it held the debouncer mutex
|
||||
//! across `shared_picker.write()`, while `stop()` takes the
|
||||
//! debouncer mutex under the caller's write guard — inverse
|
||||
//! lock orders, classic deadlock.
|
||||
//!
|
||||
//! macOS FSEvents is the reliable reproducer for (1) because fresh
|
||||
//! `fs::write()` calls inside a just-watched temp dir queue events
|
||||
//! faster than the debounce tick can drain them. Creating new
|
||||
//! subdirectories exercises (2) via the owner thread's `watch_tx`.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
|
||||
|
||||
/// Run `f` on a worker thread, require it to finish within `timeout`,
|
||||
/// panic with `msg` otherwise. The caller gets to describe what the
|
||||
/// worker is doing so a hung test produces an actionable message.
|
||||
fn run_with_deadlock_guard(
|
||||
msg: &'static str,
|
||||
timeout: Duration,
|
||||
f: impl FnOnce() + Send + 'static,
|
||||
) {
|
||||
let (done_tx, done_rx) = mpsc::channel::<()>();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("deadlock-guard-worker".into())
|
||||
.spawn(move || {
|
||||
f();
|
||||
let _ = done_tx.send(());
|
||||
})
|
||||
.expect("spawn worker");
|
||||
|
||||
match done_rx.recv_timeout(timeout) {
|
||||
Ok(()) => {}
|
||||
Err(_) => panic!("{msg}"),
|
||||
}
|
||||
worker.join().expect("worker panicked");
|
||||
}
|
||||
|
||||
fn make_watched_picker(base: &std::path::Path) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::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(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
"initial scan never completed"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(10)),
|
||||
"watcher never installed"
|
||||
);
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
/// Hazard (1): debouncer event handler is waiting on `shared_picker.write()`
|
||||
/// while the caller joins it from under the same guard.
|
||||
#[test]
|
||||
fn stop_background_monitor_under_write_lock_does_not_deadlock_file_events() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().to_path_buf();
|
||||
|
||||
for i in 0..4 {
|
||||
fs::write(base.join(format!("file_{i}.txt")), format!("seed {i}\n")).unwrap();
|
||||
}
|
||||
|
||||
let (shared_picker, _shared_frecency) = make_watched_picker(&base);
|
||||
|
||||
// Produce enough filesystem churn that the debouncer has events
|
||||
// queued and is likely mid-handler by the time we call stop.
|
||||
for round in 0..8 {
|
||||
for i in 0..4 {
|
||||
let path = base.join(format!("file_{i}.txt"));
|
||||
fs::write(&path, format!("edit {round}-{i}\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Give the kernel time to deliver events into the debouncer queue
|
||||
// (50 ms = default debouncer tick).
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
|
||||
let sp = shared_picker.clone();
|
||||
run_with_deadlock_guard(
|
||||
"stop_background_monitor() deadlocked under shared_picker.write() — \
|
||||
the debouncer thread is likely waiting on the same write lock \
|
||||
while we join it",
|
||||
Duration::from_secs(5),
|
||||
move || {
|
||||
let mut guard = sp.write().expect("write lock");
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Hazard (2): owner thread holds the debouncer mutex while waiting
|
||||
/// on `shared_picker.write()`, and `stop()` takes the debouncer mutex
|
||||
/// under the caller's write guard.
|
||||
#[test]
|
||||
fn stop_background_monitor_under_write_lock_does_not_deadlock_new_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path().to_path_buf();
|
||||
|
||||
fs::write(base.join("seed.txt"), "seed\n").unwrap();
|
||||
|
||||
let (shared_picker, _shared_frecency) = make_watched_picker(&base);
|
||||
|
||||
// Create a burst of new subdirectories with files inside. On Linux
|
||||
// the watcher event thread sends each new dir to `watch_tx`, and
|
||||
// the owner thread processes them (taking the debouncer mutex +
|
||||
// `shared_picker.write()`). On macOS the owner thread still runs
|
||||
// `track_files_from_new_directories`, which takes the write lock.
|
||||
for d in 0..8 {
|
||||
let sub = base.join(format!("sub_{d}"));
|
||||
fs::create_dir(&sub).unwrap();
|
||||
for f in 0..4 {
|
||||
fs::write(sub.join(format!("f_{f}.txt")), format!("{d}-{f}\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
|
||||
let sp = shared_picker.clone();
|
||||
run_with_deadlock_guard(
|
||||
"stop_background_monitor() deadlocked under shared_picker.write() — \
|
||||
the watcher owner thread is likely holding the debouncer mutex and \
|
||||
waiting on the same write lock while we try to take the debouncer \
|
||||
mutex to tear it down",
|
||||
Duration::from_secs(5),
|
||||
move || {
|
||||
let mut guard = sp.write().expect("write lock");
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
|
||||
|
||||
/// Thread comm names Linux exposes via `/proc/self/task/*/comm` are
|
||||
/// capped at `TASK_COMM_LEN - 1 = 15` bytes. Our owner thread is named
|
||||
/// `"fff-watcher-owner"` (17 bytes), so what actually appears in
|
||||
/// `/proc` is the 15-byte truncation below.
|
||||
const WATCHER_OWNER_THREAD_NAME: &str = "fff-watcher-own";
|
||||
|
||||
/// Walk `/proc/self/task/*/comm` and return how many live threads
|
||||
/// carry `name` as their `comm`.
|
||||
fn count_live_threads_named(name: &str) -> usize {
|
||||
let Ok(dir) = fs::read_dir("/proc/self/task") else {
|
||||
return 0;
|
||||
};
|
||||
let mut count = 0usize;
|
||||
for entry in dir.flatten() {
|
||||
let comm_path = entry.path().join("comm");
|
||||
if let Ok(content) = fs::read_to_string(&comm_path) {
|
||||
if content.trim_end() == name {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Poll until the thread count matches `expected` or we hit `timeout`.
|
||||
fn wait_for_thread_count(name: &str, expected: usize, timeout: Duration) -> usize {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let count = count_live_threads_named(name);
|
||||
if count == expected {
|
||||
return count;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return count;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_repo(base: &std::path::Path) {
|
||||
fs::create_dir_all(base.join("src")).unwrap();
|
||||
fs::write(base.join("README.md"), "# seed\n").unwrap();
|
||||
fs::write(base.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
fs::write(base.join("src/lib.rs"), "// lib\n").unwrap();
|
||||
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["init", "-q", "-b", "main"])
|
||||
.current_dir(base)
|
||||
.output();
|
||||
}
|
||||
|
||||
fn spawn_watched_picker(base: PathBuf) -> (SharedFilePicker, SharedFrecency) {
|
||||
let shared_picker = SharedFilePicker::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(),
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("FilePicker::new_with_shared_state");
|
||||
|
||||
assert!(
|
||||
shared_picker.wait_for_scan(Duration::from_secs(10)),
|
||||
"initial scan did not complete"
|
||||
);
|
||||
assert!(
|
||||
shared_picker.wait_for_watcher(Duration::from_secs(10)),
|
||||
"watcher did not install"
|
||||
);
|
||||
|
||||
(shared_picker, shared_frecency)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_threads_do_not_leak_across_picker_lifetimes() {
|
||||
// this is needed because I run this within neovim with it's own fff owner thread lmao
|
||||
let baseline = count_live_threads_named(WATCHER_OWNER_THREAD_NAME);
|
||||
|
||||
const PICKER_COUNT: usize = 4;
|
||||
|
||||
let mut tmpdirs: Vec<TempDir> = (0..PICKER_COUNT)
|
||||
.map(|_| TempDir::new().expect("mktemp"))
|
||||
.collect();
|
||||
for td in &tmpdirs {
|
||||
seed_repo(td.path());
|
||||
}
|
||||
|
||||
let mut pickers: Vec<(SharedFilePicker, SharedFrecency)> = tmpdirs
|
||||
.iter()
|
||||
.map(|td| spawn_watched_picker(td.path().canonicalize().expect("canonicalize tmp")))
|
||||
.collect();
|
||||
|
||||
let peak = wait_for_thread_count(
|
||||
WATCHER_OWNER_THREAD_NAME,
|
||||
baseline + PICKER_COUNT,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(
|
||||
peak,
|
||||
baseline + PICKER_COUNT,
|
||||
"expected {} watcher-owner threads alive (baseline {} + {} pickers), saw {}",
|
||||
baseline + PICKER_COUNT,
|
||||
baseline,
|
||||
PICKER_COUNT,
|
||||
peak,
|
||||
);
|
||||
|
||||
for i in 0..PICKER_COUNT {
|
||||
let expected_remaining = baseline + PICKER_COUNT - (i + 1);
|
||||
let (sp, sf) = pickers.remove(0);
|
||||
drop(sp);
|
||||
drop(sf);
|
||||
let count = wait_for_thread_count(
|
||||
WATCHER_OWNER_THREAD_NAME,
|
||||
expected_remaining,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(
|
||||
count,
|
||||
expected_remaining,
|
||||
"after dropping picker {}/{}: expected {} owner threads, saw {}",
|
||||
i + 1,
|
||||
PICKER_COUNT,
|
||||
expected_remaining,
|
||||
count,
|
||||
);
|
||||
}
|
||||
tmpdirs.clear();
|
||||
|
||||
let after_stage1 = count_live_threads_named(WATCHER_OWNER_THREAD_NAME);
|
||||
assert_eq!(
|
||||
after_stage1, baseline,
|
||||
"stage 1 leaked watcher-owner threads: baseline {}, observed {}",
|
||||
baseline, after_stage1,
|
||||
);
|
||||
|
||||
const ROUNDS: usize = 3;
|
||||
|
||||
for round in 0..ROUNDS {
|
||||
let tmp = TempDir::new().expect("mktemp");
|
||||
seed_repo(tmp.path());
|
||||
let base = tmp.path().canonicalize().expect("canonicalize tmp");
|
||||
|
||||
let (sp, sf) = spawn_watched_picker(base);
|
||||
|
||||
let during = wait_for_thread_count(
|
||||
WATCHER_OWNER_THREAD_NAME,
|
||||
baseline + 1,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(
|
||||
during,
|
||||
baseline + 1,
|
||||
"round {round}: expected 1 owner thread during run, saw {during} \
|
||||
(baseline {baseline})",
|
||||
);
|
||||
|
||||
drop(sp);
|
||||
drop(sf);
|
||||
drop(tmp);
|
||||
|
||||
let after =
|
||||
wait_for_thread_count(WATCHER_OWNER_THREAD_NAME, baseline, Duration::from_secs(5));
|
||||
assert_eq!(
|
||||
after, baseline,
|
||||
"round {round}: owner thread leaked after teardown \
|
||||
(baseline {baseline}, observed {after})",
|
||||
);
|
||||
}
|
||||
|
||||
let final_count = count_live_threads_named(WATCHER_OWNER_THREAD_NAME);
|
||||
assert_eq!(
|
||||
final_count, baseline,
|
||||
"watcher-owner threads leaked past the end of the test \
|
||||
(baseline {}, final {})",
|
||||
baseline, final_count,
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name = "fff-grep"
|
||||
description = "File grepping logic for fff"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-mcp"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
|
||||
license = "MIT"
|
||||
@@ -14,8 +14,8 @@ 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" }
|
||||
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.7.1" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.7.1" }
|
||||
mimalloc = { workspace = true }
|
||||
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
|
||||
schemars = "1.0"
|
||||
|
||||
+43
-49
@@ -15,7 +15,7 @@ mod update_check;
|
||||
use clap::Parser;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
use fff::{FFFMode, SharedFrecency, SharedPicker};
|
||||
use fff::{FFFMode, SharedFilePicker, SharedFrecency};
|
||||
use git2::Repository;
|
||||
use mimalloc::MiMalloc;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
@@ -136,6 +136,19 @@ pub(crate) struct Args {
|
||||
#[arg(long = "no-warmup")]
|
||||
no_warmup: bool,
|
||||
|
||||
/// Disable the content index built after the initial scan.
|
||||
/// This makes grep calls slower but consumes less RAM (recommended to not turn off)
|
||||
no_content_indexing: bool,
|
||||
|
||||
/// Explicitly enable content indexing even when `--no-warmup` is set.
|
||||
#[arg(long = "content-indexing")]
|
||||
content_indexing: bool,
|
||||
|
||||
/// Disable the background file-system watcher. Files are scanned once
|
||||
/// at startup but not monitored for changes.
|
||||
#[arg(long = "no-watch")]
|
||||
no_watch: 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.
|
||||
@@ -148,43 +161,10 @@ pub(crate) struct Args {
|
||||
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.
|
||||
/// Resolve default paths for the log file.
|
||||
/// Database paths (frecency, history) must be explicitly provided via flags.
|
||||
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
|
||||
// Ensure parent directories exist for database paths when provided
|
||||
for path in [&args.frecency_db_path, &args.history_db_path]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -195,6 +175,8 @@ fn resolve_defaults(args: &mut Args) {
|
||||
}
|
||||
|
||||
if args.log_file.is_none() {
|
||||
let home = dirs_home();
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
args.log_file = Some(if is_windows {
|
||||
format!("{}\\AppData\\Local\\fff_mcp.log", home)
|
||||
} else {
|
||||
@@ -250,32 +232,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
};
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::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);
|
||||
if let Some(frecency_db_path) = args.frecency_db_path {
|
||||
match FrecencyTracker::open(&frecency_db_path) {
|
||||
Ok(tracker) => {
|
||||
let _ = shared_frecency.init(tracker);
|
||||
let _ = shared_frecency.spawn_gc(frecency_db_path);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to init frecency db: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content indexing follows warmup by default (backward compat), unless
|
||||
// the user explicitly opts in via --content-indexing or out via
|
||||
// --no-content-indexing.
|
||||
let enable_content_indexing = if args.content_indexing {
|
||||
true
|
||||
} else if args.no_content_indexing {
|
||||
false
|
||||
} else {
|
||||
!args.no_warmup
|
||||
};
|
||||
|
||||
// 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,
|
||||
enable_mmap_cache: !args.no_warmup,
|
||||
enable_content_indexing,
|
||||
watch: !args.no_watch,
|
||||
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))?;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
//! 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::file_picker::FilePicker;
|
||||
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")
|
||||
@@ -23,7 +20,6 @@ fn frecency_word(score: i32) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
@@ -57,7 +53,6 @@ impl OutputMode {
|
||||
|
||||
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()
|
||||
@@ -69,11 +64,8 @@ fn size_tag(bytes: u64) -> String {
|
||||
|
||||
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(
|
||||
@@ -81,34 +73,19 @@ fn trauncate_line_for_ai(
|
||||
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();
|
||||
// Leading whitespace is already stripped by core (trim_whitespace option).
|
||||
// Only strip trailing whitespace here.
|
||||
let trimmed = line.trim_end();
|
||||
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
|
||||
if let Some(ranges) = match_ranges
|
||||
&& let Some(&(match_start, match_end)) = ranges.first()
|
||||
{
|
||||
let match_start = match_start as usize;
|
||||
@@ -141,7 +118,6 @@ fn trauncate_line_for_ai(
|
||||
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();
|
||||
@@ -153,7 +129,6 @@ fn floor_char_boundary(s: &str, index: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// Ceil to a valid char boundary
|
||||
fn ceil_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
return s.len();
|
||||
@@ -165,7 +140,6 @@ fn ceil_char_boundary(s: &str, index: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// Collected file metadata for the first match per file.
|
||||
struct FileMeta<'a> {
|
||||
file: &'a FileItem,
|
||||
line_number: u64,
|
||||
@@ -175,19 +149,16 @@ struct FileMeta<'a> {
|
||||
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,
|
||||
pub picker: &'a FilePicker,
|
||||
}
|
||||
|
||||
impl GrepFormatter<'_> {
|
||||
@@ -197,11 +168,11 @@ impl GrepFormatter<'_> {
|
||||
files,
|
||||
total_matched,
|
||||
next_file_offset,
|
||||
regex_fallback_error,
|
||||
output_mode,
|
||||
max_results,
|
||||
show_context,
|
||||
auto_expand_defs,
|
||||
picker,
|
||||
} = *self;
|
||||
|
||||
let items = if matches.len() > max_results {
|
||||
@@ -217,11 +188,12 @@ impl GrepFormatter<'_> {
|
||||
next_file_offset,
|
||||
auto_expand_defs,
|
||||
cursor_store,
|
||||
picker,
|
||||
);
|
||||
}
|
||||
|
||||
if output_mode == OutputMode::Count {
|
||||
return format_count(items, files, next_file_offset, cursor_store);
|
||||
return format_count(items, files, next_file_offset, cursor_store, picker);
|
||||
}
|
||||
|
||||
// output_mode == usage
|
||||
@@ -242,27 +214,23 @@ impl GrepFormatter<'_> {
|
||||
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 = "";
|
||||
let file_preview = collect_file_preview(items, files, picker);
|
||||
let mut content_def_file = String::new();
|
||||
let mut content_first_file = String::new();
|
||||
for fm in &file_preview {
|
||||
if content_first_file.is_empty() {
|
||||
content_first_file = fm.file.relative_path();
|
||||
content_first_file = fm.file.relative_path(picker);
|
||||
}
|
||||
if content_def_file.is_empty() && fm.is_definition {
|
||||
content_def_file = fm.file.relative_path();
|
||||
content_def_file = fm.file.relative_path(picker);
|
||||
}
|
||||
}
|
||||
|
||||
let content_suggest = if !content_def_file.is_empty() {
|
||||
content_def_file
|
||||
&content_def_file
|
||||
} else {
|
||||
content_first_file
|
||||
&content_first_file
|
||||
};
|
||||
if !content_suggest.is_empty() {
|
||||
let file_count = file_preview.len();
|
||||
@@ -285,7 +253,7 @@ impl GrepFormatter<'_> {
|
||||
// Detailed content (subject to budget)
|
||||
let mut char_count = 0usize;
|
||||
let mut shown_count = 0usize;
|
||||
let mut current_file = "";
|
||||
let mut current_file = String::new();
|
||||
|
||||
// Reorder: definitions first, then usages, then imports (when auto-expanding)
|
||||
let sorted_items: Vec<usize> = if auto_expand_defs {
|
||||
@@ -310,8 +278,9 @@ impl GrepFormatter<'_> {
|
||||
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();
|
||||
let file_rel_path = file.relative_path(picker);
|
||||
if file_rel_path != current_file {
|
||||
current_file = file_rel_path;
|
||||
match_lines.push(current_file.to_string());
|
||||
}
|
||||
|
||||
@@ -358,18 +327,19 @@ impl GrepFormatter<'_> {
|
||||
}
|
||||
|
||||
// Auto-expand definitions with body context
|
||||
let file_rel_for_expand = file.relative_path(picker);
|
||||
if auto_expand_defs
|
||||
&& !show_context
|
||||
&& m.is_definition
|
||||
&& !m.context_after.is_empty()
|
||||
&& !def_expanded_files.contains(file.relative_path())
|
||||
&& !def_expanded_files.contains(&file_rel_for_expand)
|
||||
{
|
||||
let expand_limit = if def_expanded_files.is_empty() {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_files.insert(file.relative_path());
|
||||
def_expanded_files.insert(file_rel_for_expand);
|
||||
let start_line = m.line_number + 1;
|
||||
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
|
||||
if ctx.trim().is_empty() {
|
||||
@@ -408,27 +378,28 @@ fn format_files_with_matches(
|
||||
next_file_offset: usize,
|
||||
auto_expand_defs: bool,
|
||||
cursor_store: &mut CursorStore,
|
||||
picker: &FilePicker,
|
||||
) -> String {
|
||||
let file_map = collect_file_preview(items, files);
|
||||
let file_map = collect_file_preview(items, files, picker);
|
||||
|
||||
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 = "";
|
||||
let mut first_def_file = String::new();
|
||||
let mut first_file = String::new();
|
||||
for fm in &file_map {
|
||||
if first_file.is_empty() {
|
||||
first_file = fm.file.relative_path();
|
||||
first_file = fm.file.relative_path(picker);
|
||||
}
|
||||
if first_def_file.is_empty() && fm.is_definition {
|
||||
first_def_file = fm.file.relative_path();
|
||||
first_def_file = fm.file.relative_path(picker);
|
||||
}
|
||||
}
|
||||
let suggest_path = if !first_def_file.is_empty() {
|
||||
first_def_file
|
||||
&first_def_file
|
||||
} else {
|
||||
first_file
|
||||
&first_file
|
||||
};
|
||||
|
||||
if !suggest_path.is_empty() {
|
||||
@@ -456,7 +427,7 @@ fn format_files_with_matches(
|
||||
let def_tag = if is_def { " [def]" } else { "" };
|
||||
lines.push(format!(
|
||||
"{}{}{}",
|
||||
fm.file.relative_path(),
|
||||
fm.file.relative_path(picker),
|
||||
def_tag,
|
||||
size_tag(fm.file.size)
|
||||
));
|
||||
@@ -522,13 +493,15 @@ fn format_count(
|
||||
files: &[&FileItem],
|
||||
next_file_offset: usize,
|
||||
cursor_store: &mut CursorStore,
|
||||
picker: &FilePicker,
|
||||
) -> String {
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
let mut order: Vec<&str> = Vec::new();
|
||||
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut order: Vec<String> = 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);
|
||||
let file = files[m.file_index];
|
||||
let path = file.relative_path(picker);
|
||||
let count = counts.entry(path.to_string()).or_insert_with(|| {
|
||||
order.push(path.to_string());
|
||||
0
|
||||
});
|
||||
*count += 1;
|
||||
@@ -536,7 +509,7 @@ fn format_count(
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for path in &order {
|
||||
lines.push(format!("{}: {}", path, counts[*path]));
|
||||
lines.push(format!("{}: {}", path, counts[path.as_str()]));
|
||||
}
|
||||
if next_file_offset > 0 {
|
||||
let cursor_id = cursor_store.store(next_file_offset);
|
||||
@@ -545,12 +518,16 @@ fn format_count(
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<FileMeta<'a>> {
|
||||
fn collect_file_preview<'a>(
|
||||
items: &[GrepMatch],
|
||||
files: &[&'a FileItem],
|
||||
picker: &FilePicker,
|
||||
) -> 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()) {
|
||||
if seen.insert(file.relative_path(picker)) {
|
||||
file_preview.push(FileMeta {
|
||||
file,
|
||||
line_number: m.line_number,
|
||||
@@ -569,26 +546,29 @@ 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");
|
||||
fn trunc_strips_trailing_whitespace() {
|
||||
// Leading whitespace is now stripped by core's trim_whitespace option.
|
||||
// This function only strips trailing 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)];
|
||||
fn trunc_preserves_pre_trimmed_match_ranges() {
|
||||
// Core already stripped leading whitespace and adjusted offsets,
|
||||
// so "hello" arrives with match at bytes 0..5.
|
||||
let line = "hello";
|
||||
let ranges = [(0, 5)];
|
||||
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)];
|
||||
// Core already stripped leading whitespace; offsets are pre-adjusted.
|
||||
let line = format!("match_here{}", "x".repeat(200));
|
||||
let ranges = [(0u32, 10u32)];
|
||||
let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
|
||||
assert!(result.contains("match_here"));
|
||||
assert!(result.len() <= 55); // budget + ellipsis chars
|
||||
|
||||
+106
-48
@@ -10,17 +10,29 @@ 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::grep::{GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff::types::{FileItem, PaginationArgs};
|
||||
use fff::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff::{FuzzySearchOptions, QueryParser, SharedFilePicker, SharedFrecency};
|
||||
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.
|
||||
/// Normalize the caller-supplied `maxResults`.
|
||||
///
|
||||
/// `None`, `Some(0)`, and non-positive / non-finite values fall back to
|
||||
/// `default`. Issue #400 reported that grep returned 0 items for
|
||||
/// `maxResults: 0` while `find_files` returned the entire dataset; treating
|
||||
/// 0 as "use the default" makes both tools behave consistently.
|
||||
fn normalize_max_results(raw: Option<f64>, default: usize) -> usize {
|
||||
match raw {
|
||||
None => default,
|
||||
Some(v) if v <= 0.0 || !v.is_finite() => default,
|
||||
Some(v) => (v.round() as usize).max(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
@@ -31,7 +43,6 @@ fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute grep search options from output mode and context settings.
|
||||
fn make_grep_options(
|
||||
output_mode: OutputMode,
|
||||
mode: GrepMode,
|
||||
@@ -64,6 +75,8 @@ fn make_grep_options(
|
||||
before_context: ctx_lines,
|
||||
after_context: after_ctx,
|
||||
classify_definitions: true,
|
||||
trim_whitespace: true,
|
||||
abort_signal: None,
|
||||
},
|
||||
auto_expand,
|
||||
)
|
||||
@@ -72,6 +85,9 @@ fn make_grep_options(
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct FindFilesParams {
|
||||
/// Fuzzy search query. Supports path prefixes and glob constraints.
|
||||
// `pattern` alias for consistency with grep's alias and the common
|
||||
// file-search parameter name (#311).
|
||||
#[serde(alias = "pattern")]
|
||||
pub query: String,
|
||||
/// Max results (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
@@ -85,6 +101,10 @@ pub struct FindFilesParams {
|
||||
pub struct GrepParams {
|
||||
/// Search text or regex query with optional constraint prefixes.
|
||||
/// Matches within single lines only — use ONE specific term, not multiple words.
|
||||
// `pattern` alias: LLMs that have seen multi_grep (which uses `patterns`)
|
||||
// routinely call grep with `pattern`; accept it instead of erroring out
|
||||
// with an unhelpful "missing field `query`" (#311).
|
||||
#[serde(alias = "pattern")]
|
||||
pub query: String,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
@@ -161,7 +181,7 @@ pub struct MultiGrepParams {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FffServer {
|
||||
picker: SharedPicker,
|
||||
picker: SharedFilePicker,
|
||||
#[allow(dead_code)]
|
||||
frecency: SharedFrecency,
|
||||
cursor_store: Arc<Mutex<CursorStore>>,
|
||||
@@ -170,7 +190,7 @@ pub struct FffServer {
|
||||
}
|
||||
|
||||
impl FffServer {
|
||||
pub fn new(picker: SharedPicker, frecency: SharedFrecency) -> Self {
|
||||
pub fn new(picker: SharedFilePicker, frecency: SharedFrecency) -> Self {
|
||||
Self {
|
||||
picker,
|
||||
frecency,
|
||||
@@ -180,7 +200,6 @@ impl FffServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the initial file scan to complete.
|
||||
#[allow(dead_code)]
|
||||
pub fn wait_for_scan(&self) {
|
||||
loop {
|
||||
@@ -198,15 +217,12 @@ impl FffServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -220,11 +236,6 @@ impl FffServer {
|
||||
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,
|
||||
@@ -283,11 +294,11 @@ impl FffServer {
|
||||
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,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
@@ -310,11 +321,12 @@ impl FffServer {
|
||||
"0 exact matches. {} approximate:",
|
||||
fuzzy_result.matches.len()
|
||||
));
|
||||
let mut current_file = "";
|
||||
let mut current_file = String::new();
|
||||
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();
|
||||
let file_rel = file.relative_path(picker);
|
||||
if file_rel != current_file {
|
||||
current_file = file_rel;
|
||||
lines.push(current_file.to_string());
|
||||
}
|
||||
lines.push(format!(" {}: {}", m.line_number, m.line_content));
|
||||
@@ -339,8 +351,7 @@ impl FffServer {
|
||||
limit: 1,
|
||||
},
|
||||
};
|
||||
let file_result =
|
||||
FilePicker::fuzzy_search(picker.get_files(), &file_query, None, file_opts);
|
||||
let file_result = picker.fuzzy_search(&file_query, None, file_opts);
|
||||
if let (Some(top), Some(score)) =
|
||||
(file_result.items.first(), file_result.scores.first())
|
||||
{
|
||||
@@ -349,7 +360,7 @@ impl FffServer {
|
||||
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()
|
||||
top.relative_path(picker)
|
||||
))]));
|
||||
}
|
||||
}
|
||||
@@ -372,11 +383,11 @@ impl FffServer {
|
||||
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,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
@@ -399,7 +410,7 @@ impl FffServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<FindFilesParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20.0).round() as usize; // safe
|
||||
let max_results = normalize_max_results(params.max_results, 20);
|
||||
let query = ¶ms.query;
|
||||
|
||||
let page_offset = params
|
||||
@@ -414,8 +425,6 @@ impl FffServer {
|
||||
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,
|
||||
@@ -431,7 +440,7 @@ impl FffServer {
|
||||
|
||||
let parser = QueryParser::default();
|
||||
let fff_query = parser.parse(query);
|
||||
let result = FilePicker::fuzzy_search(files, &fff_query, None, make_opts(page_offset));
|
||||
let result = picker.fuzzy_search(&fff_query, None, make_opts(page_offset));
|
||||
let total_files = result.total_files;
|
||||
|
||||
// Auto-retry with fewer terms if 3+ words return 0 results
|
||||
@@ -442,12 +451,7 @@ impl FffServer {
|
||||
if result.items.is_empty() && words.len() >= 3 && page_offset == 0 {
|
||||
if let Some(shorter) = &shorter {
|
||||
let shorter_query = parser.parse(shorter);
|
||||
let retry = FilePicker::fuzzy_search(
|
||||
files,
|
||||
&shorter_query,
|
||||
/*query_tracker=*/ None,
|
||||
make_opts(0),
|
||||
);
|
||||
let retry = picker.fuzzy_search(&shorter_query, None, make_opts(0));
|
||||
|
||||
(retry.items, retry.scores, retry.total_matched)
|
||||
} else {
|
||||
@@ -472,12 +476,12 @@ impl FffServer {
|
||||
if is_exact_match {
|
||||
lines.push(format!(
|
||||
"→ Read {} (exact match!)",
|
||||
top_item.relative_path()
|
||||
top_item.relative_path(picker)
|
||||
));
|
||||
} 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()
|
||||
top_item.relative_path(picker)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -492,7 +496,7 @@ impl FffServer {
|
||||
for item in &items {
|
||||
lines.push(format!(
|
||||
"{}{}",
|
||||
item.relative_path(),
|
||||
item.relative_path(picker),
|
||||
file_suffix(item.git_status, item.total_frecency_score())
|
||||
));
|
||||
}
|
||||
@@ -518,7 +522,7 @@ impl FffServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<GrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20.0) as usize;
|
||||
let max_results = normalize_max_results(params.max_results, 20);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let parsed = QueryParser::new(AiGrepConfig).parse(¶ms.query);
|
||||
@@ -560,7 +564,7 @@ impl FffServer {
|
||||
|
||||
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 max_results = normalize_max_results(params.max_results, 20);
|
||||
let context = params.context.map(|v| v.round() as usize);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
@@ -581,17 +585,13 @@ impl FffServer {
|
||||
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 result = picker.multi_grep(&patterns_refs, constraints, &options);
|
||||
let file_refs: Vec<&FileItem> = result.files.to_vec();
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
@@ -613,8 +613,7 @@ impl FffServer {
|
||||
};
|
||||
|
||||
let parsed = parser.parse(&full_query);
|
||||
let fb_result =
|
||||
grep::grep_search(files, &parsed, &fallback_options, budget, None, None, None);
|
||||
let fb_result = picker.grep(&parsed, &fallback_options);
|
||||
|
||||
if !fb_result.matches.is_empty() {
|
||||
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
|
||||
@@ -624,11 +623,11 @@ impl FffServer {
|
||||
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,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
@@ -655,11 +654,11 @@ impl FffServer {
|
||||
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,
|
||||
picker,
|
||||
}
|
||||
.format(&mut cs);
|
||||
|
||||
@@ -682,3 +681,62 @@ impl ServerHandler for FffServer {
|
||||
.with_instructions(instructions)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_none_uses_default() {
|
||||
assert_eq!(normalize_max_results(None, 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_zero_uses_default() {
|
||||
// Issue #400: `maxResults: 0` must not return zero items for grep
|
||||
// while `find_files` returns the full set. Both tools now map 0 to
|
||||
// the default limit.
|
||||
assert_eq!(normalize_max_results(Some(0.0), 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_negative_uses_default() {
|
||||
assert_eq!(normalize_max_results(Some(-5.0), 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_non_finite_uses_default() {
|
||||
assert_eq!(normalize_max_results(Some(f64::NAN), 20), 20);
|
||||
assert_eq!(normalize_max_results(Some(f64::INFINITY), 20), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_max_results_rounds_and_clamps() {
|
||||
assert_eq!(normalize_max_results(Some(0.4), 20), 1);
|
||||
assert_eq!(normalize_max_results(Some(10.0), 20), 10);
|
||||
assert_eq!(normalize_max_results(Some(10.7), 20), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_params_accepts_pattern_alias() {
|
||||
// Issue #311: LLMs flip between `query` and `pattern`; accept both.
|
||||
let via_query: GrepParams =
|
||||
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
|
||||
assert_eq!(via_query.query, "foo");
|
||||
|
||||
let via_pattern: GrepParams =
|
||||
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
|
||||
assert_eq!(via_pattern.query, "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_files_params_accepts_pattern_alias() {
|
||||
let via_query: FindFilesParams =
|
||||
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
|
||||
assert_eq!(via_query.query, "foo");
|
||||
|
||||
let via_pattern: FindFilesParams =
|
||||
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
|
||||
assert_eq!(via_pattern.query, "foo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-nvim"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -37,9 +37,24 @@ 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"
|
||||
|
||||
[[bin]]
|
||||
name = "fuzzy_grep_test"
|
||||
path = "src/bin/fuzzy_grep_test.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "test_memory_leak"
|
||||
path = "src/bin/test_memory_leak.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_ci_memmem"
|
||||
path = "src/bin/bench_ci_memmem.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_lmdb_parallel"
|
||||
path = "src/bin/bench_lmdb_parallel.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
@@ -49,8 +64,10 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
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" }
|
||||
fff = { package = "fff-search", path = "../fff-core", version = "0.7.1", features = [
|
||||
"mimalloc-collect",
|
||||
] }
|
||||
fff-query-parser = { path = "../fff-query-parser", version = "0.7.1" }
|
||||
|
||||
# External dependencies
|
||||
blake3 = "1.8.2"
|
||||
@@ -61,11 +78,12 @@ git2 = { workspace = true }
|
||||
glidesort = "0.1"
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
mimalloc = "0.1.47"
|
||||
mimalloc = { version = "0.1.47", features = ["local_dynamic_tls"] }
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.6"
|
||||
notify = { workspace = true }
|
||||
notify-debouncer-full = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
once_cell = "1.20.2"
|
||||
pathdiff = "0.2.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -80,6 +98,10 @@ tempfile = "3.8"
|
||||
name = "indexing_and_search"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "grep_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::{
|
||||
FilePickerOptions, GrepMode, GrepSearchOptions, SharedFilePicker, SharedFrecency,
|
||||
parse_grep_query,
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
struct TestData {
|
||||
shared_picker: SharedFilePicker,
|
||||
}
|
||||
|
||||
static SETUP: OnceLock<TestData> = OnceLock::new();
|
||||
|
||||
fn big_repo_path() -> String {
|
||||
if let Some(path) = std::env::var_os("BIG_REPO_PATH") {
|
||||
return path.to_string_lossy().into_owned();
|
||||
}
|
||||
|
||||
let candidates = ["./big-repo", "../../big-repo"];
|
||||
for p in &candidates {
|
||||
if std::path::Path::new(p).exists() {
|
||||
return p.to_string();
|
||||
}
|
||||
}
|
||||
panic!(
|
||||
"./big-repo not found. Run from workspace root:\n \
|
||||
git clone --depth 1 https://github.com/torvalds/linux.git big-repo"
|
||||
);
|
||||
}
|
||||
|
||||
fn setup() -> &'static TestData {
|
||||
SETUP.get_or_init(|| {
|
||||
let path = big_repo_path();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
eprintln!("Initializing FilePicker for {:?}...", path);
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: path,
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("create picker");
|
||||
|
||||
eprintln!("Waiting for scan completion...");
|
||||
shared_picker.wait_for_scan(Duration::from_secs(120));
|
||||
|
||||
eprintln!("Waiting for warmup (bigram index)...");
|
||||
loop {
|
||||
let guard = shared_picker.read().expect("read lock");
|
||||
let picker = guard.as_ref().expect("picker present");
|
||||
let progress = picker.get_scan_progress();
|
||||
if progress.is_warmup_complete {
|
||||
let file_count = picker.get_files().len();
|
||||
eprintln!("Ready: {} files indexed, bigram built", file_count);
|
||||
break;
|
||||
}
|
||||
drop(guard);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
TestData { shared_picker }
|
||||
})
|
||||
}
|
||||
|
||||
fn setup_cold() -> SharedFilePicker {
|
||||
let path = big_repo_path();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: path,
|
||||
enable_mmap_cache: false,
|
||||
enable_content_indexing: false,
|
||||
mode: FFFMode::Neovim,
|
||||
watch: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("create picker");
|
||||
|
||||
shared_picker.wait_for_scan(Duration::from_secs(120));
|
||||
shared_picker
|
||||
}
|
||||
|
||||
fn plain_options() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzzy_options() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
mode: GrepMode::Fuzzy,
|
||||
..plain_options()
|
||||
}
|
||||
}
|
||||
|
||||
const PLAIN_QUERIES: &[(&str, &str)] = &[
|
||||
("2char_if", "if"),
|
||||
("common_return", "return"),
|
||||
("func_mutex_lock", "mutex_lock"),
|
||||
("struct_inode_ops", "inode_operations"),
|
||||
("define_MODULE_LICENSE", "MODULE_LICENSE"),
|
||||
("rare_phylink_ethtool", "phylink_ethtool"),
|
||||
("include", "#include"),
|
||||
("comment_TODO", "TODO"),
|
||||
("type_struct_file", "struct file"),
|
||||
("error_EINVAL", "err = -EINVAL"),
|
||||
("long_static_int_init", "static int __init"),
|
||||
("very_common_int", "int"),
|
||||
("single_char_x", "x"),
|
||||
("path_printk_c", "printk *.c"),
|
||||
("dir_mutex_kernel", "mutex /kernel/"),
|
||||
];
|
||||
|
||||
const FUZZY_QUERIES: &[(&str, &str)] = &[
|
||||
("exact_mutex_lock", "mutex_lock"),
|
||||
("typo_mutx_lock", "mutx_lock"),
|
||||
("camel_InodeOps", "InodeOps"),
|
||||
("abbrev_sched_rt", "sched_rt"),
|
||||
("short_kfr", "kfr"),
|
||||
("common_return", "return"),
|
||||
("define_MODULE_LICENSE", "MODULE_LICENSE"),
|
||||
("struct_file_ops", "file_operations"),
|
||||
("long_static_int_init", "static_int_init"),
|
||||
("path_printk_c", "printk *.c"),
|
||||
];
|
||||
|
||||
fn bench_plain_warm(c: &mut Criterion) {
|
||||
let data = setup();
|
||||
let opts = plain_options();
|
||||
|
||||
let mut group = c.benchmark_group("plain_warm");
|
||||
group.sample_size(30);
|
||||
group.warm_up_time(Duration::from_secs(2));
|
||||
group.measurement_time(Duration::from_secs(5));
|
||||
|
||||
for (name, query) in PLAIN_QUERIES {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
let guard = data.shared_picker.read().expect("read lock");
|
||||
let picker = guard.as_ref().expect("picker present");
|
||||
b.iter(|| {
|
||||
let parsed = parse_grep_query(q);
|
||||
black_box(picker.grep(&parsed, &opts))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_fuzzy_warm(c: &mut Criterion) {
|
||||
let data = setup();
|
||||
let opts = fuzzy_options();
|
||||
|
||||
let mut group = c.benchmark_group("fuzzy_warm");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(2));
|
||||
group.measurement_time(Duration::from_secs(8));
|
||||
|
||||
for (name, query) in FUZZY_QUERIES {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
let guard = data.shared_picker.read().expect("read lock");
|
||||
let picker = guard.as_ref().expect("picker present");
|
||||
b.iter(|| {
|
||||
let parsed = parse_grep_query(q);
|
||||
black_box(picker.grep(&parsed, &opts))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_plain_cold(c: &mut Criterion) {
|
||||
let _ = setup();
|
||||
let opts = plain_options();
|
||||
|
||||
let queries: &[(&str, &str)] = &[
|
||||
("2char_if", "if"),
|
||||
("common_return", "return"),
|
||||
("func_mutex_lock", "mutex_lock"),
|
||||
("struct_inode_ops", "inode_operations"),
|
||||
("define_MODULE_LICENSE", "MODULE_LICENSE"),
|
||||
("rare_phylink_ethtool", "phylink_ethtool"),
|
||||
("long_static_int_init", "static int __init"),
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("plain_cold");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_millis(500));
|
||||
group.measurement_time(Duration::from_secs(10));
|
||||
|
||||
for (name, query) in queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter_with_setup(
|
||||
|| setup_cold(),
|
||||
|cold_picker| {
|
||||
let guard = cold_picker.read().expect("read lock");
|
||||
let picker = guard.as_ref().expect("picker present");
|
||||
let parsed = parse_grep_query(q);
|
||||
let result = picker.grep(&parsed, &opts);
|
||||
black_box(result.matches.len())
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_plain_warm,
|
||||
bench_fuzzy_warm,
|
||||
bench_plain_cold,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -1,9 +1,9 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::types::{ContentCacheBudget, FileItem, PaginationArgs};
|
||||
use fff::types::PaginationArgs;
|
||||
use fff::{
|
||||
FilePickerOptions, FuzzySearchOptions, GrepMode, GrepSearchOptions, QueryParser,
|
||||
SharedFrecency, SharedPicker, build_bigram_index, grep,
|
||||
SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -25,7 +25,7 @@ fn init_tracing() {
|
||||
/// Initialize FilePicker using shared state
|
||||
fn init_file_picker_internal(
|
||||
path: &str,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_picker: &SharedFilePicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), String> {
|
||||
FilePicker::new_with_shared_state(
|
||||
@@ -33,7 +33,7 @@ fn init_file_picker_internal(
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: path.to_string(),
|
||||
warmup_mmap_cache: false,
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -43,7 +43,7 @@ fn init_file_picker_internal(
|
||||
|
||||
/// Helper function to wait for scanning to complete and get file count
|
||||
fn wait_for_scan_completion(
|
||||
shared_picker: &SharedPicker,
|
||||
shared_picker: &SharedFilePicker,
|
||||
timeout_secs: u64,
|
||||
) -> Result<usize, String> {
|
||||
let start = std::time::Instant::now();
|
||||
@@ -104,20 +104,8 @@ fn wait_for_scan_completion(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files from the shared picker
|
||||
fn get_files_snapshot(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().to_vec())
|
||||
} else {
|
||||
Err("FilePicker not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up shared state
|
||||
fn cleanup_shared_state(shared_picker: &SharedPicker) {
|
||||
fn cleanup_shared_state(shared_picker: &SharedFilePicker) {
|
||||
if let Ok(mut picker_guard) = shared_picker.write() {
|
||||
if let Some(mut picker) = picker_guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
@@ -125,8 +113,8 @@ fn cleanup_shared_state(shared_picker: &SharedPicker) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize FilePicker once and return files snapshot
|
||||
fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String> {
|
||||
/// Initialize FilePicker once and return shared state
|
||||
fn setup_once() -> Result<(SharedFilePicker, SharedFrecency), String> {
|
||||
init_tracing();
|
||||
|
||||
let big_repo_path = PathBuf::from("./big-repo");
|
||||
@@ -138,7 +126,7 @@ fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String>
|
||||
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
eprintln!(" Path: {:?}", canonical_path);
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
init_file_picker_internal(
|
||||
@@ -154,8 +142,7 @@ fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String>
|
||||
file_count
|
||||
);
|
||||
|
||||
let files = get_files_snapshot(&shared_picker)?;
|
||||
Ok((files, shared_picker, shared_frecency))
|
||||
Ok((shared_picker, shared_frecency))
|
||||
}
|
||||
|
||||
/// Benchmark for indexing the big-repo directory
|
||||
@@ -184,7 +171,7 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("index_big_repo", |b| {
|
||||
b.iter(|| {
|
||||
let sp = SharedPicker::default();
|
||||
let sp = SharedFilePicker::default();
|
||||
let sf = SharedFrecency::default();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
@@ -212,7 +199,7 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark for searching with various query patterns
|
||||
fn bench_search_queries(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
let (sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprint!("Failed to setup picker {e:?}");
|
||||
@@ -220,6 +207,9 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
}
|
||||
};
|
||||
|
||||
let guard = sp.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let mut group = c.benchmark_group("search");
|
||||
group.sample_size(100);
|
||||
|
||||
@@ -237,8 +227,7 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
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),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -264,14 +253,17 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search with different thread counts
|
||||
fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
let (sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
|
||||
eprintln!("Skipping thread scaling benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let guard = sp.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let mut group = c.benchmark_group("thread_scaling");
|
||||
group.sample_size(100);
|
||||
|
||||
@@ -286,8 +278,7 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
&threads,
|
||||
|b, &threads| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -314,14 +305,17 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search with different result limits
|
||||
fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
let (sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
|
||||
eprintln!("Skipping result limit benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let guard = sp.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let mut group = c.benchmark_group("result_limits");
|
||||
group.sample_size(100);
|
||||
|
||||
@@ -333,8 +327,7 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
for limit in result_limits {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(limit), &limit, |b, &limit| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -344,10 +337,7 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: limit,
|
||||
},
|
||||
pagination: PaginationArgs { offset: 0, limit },
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
@@ -358,20 +348,23 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search algorithm performance scaling with file count
|
||||
/// Benchmark search algorithm performance with queries of varying selectivity
|
||||
fn bench_search_scalability(c: &mut Criterion) {
|
||||
let (all_files, _sp, _sf) = match setup_once() {
|
||||
let (sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
|
||||
eprintln!("Skipping scalability benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if all_files.len() < 1000 {
|
||||
let guard = sp.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
if picker.get_files().len() < 1000 {
|
||||
eprintln!(
|
||||
"⚠ Skipping scalability benchmark: need at least 1000 files, got {}",
|
||||
all_files.len()
|
||||
"Skipping scalability benchmark: need at least 1000 files, got {}",
|
||||
picker.get_files().len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -379,21 +372,19 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("search_scalability");
|
||||
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)];
|
||||
let selectivity_queries = vec![
|
||||
("broad_a", "a"),
|
||||
("medium_mod", "mod"),
|
||||
("narrow_controller", "controller"),
|
||||
("very_narrow_user_auth", "user_authentication"),
|
||||
];
|
||||
|
||||
for count in file_counts {
|
||||
if count > all_files.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let subset = &all_files[..count];
|
||||
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, _| {
|
||||
for (name, query) in selectivity_queries {
|
||||
let parsed = parser.parse(query);
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), &name, |b, _| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(subset),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -419,14 +410,17 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search performance with different ordering modes
|
||||
fn bench_search_ordering(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
let (sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
|
||||
eprintln!("Skipping ordering benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let guard = sp.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let mut group = c.benchmark_group("ordering");
|
||||
group.sample_size(100);
|
||||
|
||||
@@ -437,8 +431,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
// Benchmark normal order (descending)
|
||||
group.bench_function("normal_order", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -461,8 +454,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
// Benchmark reverse order (ascending)
|
||||
group.bench_function("reverse_order", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -485,8 +477,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
// Benchmark with large result set
|
||||
group.bench_function("normal_order_large", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed_mod),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -508,8 +499,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("reverse_order_large", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed_mod),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -532,8 +522,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
// Benchmark with small result set
|
||||
group.bench_function("normal_order_small", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -555,8 +544,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("reverse_order_small", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -581,14 +569,17 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark pagination: first page vs deep page
|
||||
fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
let (sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
|
||||
eprintln!("Skipping pagination benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let guard = sp.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let mut group = c.benchmark_group("pagination");
|
||||
group.sample_size(100);
|
||||
|
||||
@@ -600,8 +591,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
// Benchmark first page (uses partial sort optimization)
|
||||
group.bench_function("page_0_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -624,8 +614,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
// Benchmark 10th page (requires full sort, no optimization)
|
||||
group.bench_function("page_10_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -648,8 +637,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
// Benchmark 50th page (even deeper pagination)
|
||||
group.bench_function("page_50_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
let results = picker.fuzzy_search(
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -672,9 +660,9 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark grep search with bigram index prefiltering
|
||||
/// Benchmark grep search via the FilePicker public API
|
||||
fn bench_grep_search(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
let (sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping grep benchmarks: {}", e);
|
||||
@@ -682,16 +670,8 @@ fn bench_grep_search(c: &mut Criterion) {
|
||||
}
|
||||
};
|
||||
|
||||
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 guard = sp.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
|
||||
let mut group = c.benchmark_group("grep");
|
||||
group.sample_size(50);
|
||||
@@ -707,6 +687,8 @@ fn bench_grep_search(c: &mut Criterion) {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
|
||||
let test_queries = vec![
|
||||
@@ -720,34 +702,9 @@ fn bench_grep_search(c: &mut Criterion) {
|
||||
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, _| {
|
||||
group.bench_with_input(BenchmarkId::new("grep", 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,
|
||||
);
|
||||
let result = picker.grep(black_box(&parsed), black_box(&options));
|
||||
result.matches.len()
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ fn setup_tracker_with_data(entries: &[TestQueryEntry]) -> (QueryTracker, PathBuf
|
||||
.as_nanos();
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("fff_bench_{}_{}", timestamp, rand::random::<u32>()));
|
||||
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
let mut tracker = QueryTracker::open(temp_dir.to_str().unwrap()).unwrap();
|
||||
|
||||
// Insert all test data
|
||||
for entry in entries {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
/// 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;
|
||||
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "TODO"
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use std::time::Instant;
|
||||
|
||||
fn fmt_dur(us: u128) -> String {
|
||||
@@ -14,11 +14,11 @@ fn fmt_dur(us: u128) -> String {
|
||||
} else if us > 1000 {
|
||||
format!("{:.2}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}µs", us)
|
||||
format!("{}us", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_grep(files: &[fff::FileItem], index: Option<&fff::BigramFilter>, query: &str, iters: usize) {
|
||||
fn run_grep(picker: &FilePicker, query: &str, iters: usize) {
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
@@ -30,15 +30,16 @@ fn run_grep(files: &[fff::FileItem], index: Option<&fff::BigramFilter>, query: &
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
|
||||
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 result = picker.grep(&parsed, &options);
|
||||
let us = t.elapsed().as_micros();
|
||||
times_us.push(us);
|
||||
|
||||
@@ -70,17 +71,6 @@ fn run_grep(files: &[fff::FileItem], index: Option<&fff::BigramFilter>, query: &
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -105,12 +95,10 @@ fn main() {
|
||||
.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]");
|
||||
eprintln!("Usage: bench_grep_query --path <dir> --query <text> [--iters N]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -122,9 +110,17 @@ fn main() {
|
||||
eprintln!();
|
||||
|
||||
// ── 1. Scan files ──────────────────────────────────────────────────
|
||||
eprint!("[1/3] Scanning files... ");
|
||||
eprint!("[1/2] Scanning files... ");
|
||||
let t = Instant::now();
|
||||
let mut files = fff::scan_files(&canonical);
|
||||
let mut picker = FilePicker::new(fff::FilePickerOptions {
|
||||
base_path: canonical.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
mode: fff::FFFMode::Neovim,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to create FilePicker");
|
||||
picker.collect_files().expect("Failed to collect files");
|
||||
let files = picker.get_files();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(
|
||||
"{} files in {:.2}s ({} non-binary)",
|
||||
@@ -133,31 +129,10 @@ fn main() {
|
||||
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);
|
||||
// ── 2. Grep ───────────────────────────────────────────────────────
|
||||
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",
|
||||
"\n[2/2] Running grep \"{}\" x {} iterations\n",
|
||||
query, iters
|
||||
);
|
||||
run_grep(&files, Some(&index), query, iters);
|
||||
run_grep(&picker, query, iters);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/// Parallel LMDB contention bench.
|
||||
///
|
||||
/// Spawns N child processes that all open the same frecency + query-tracker
|
||||
/// dbs and hammer them. Used to measure the cost (or absence of cost) of
|
||||
/// dropping the `MDB_NOLOCK | NO_SYNC | NO_META_SYNC` env flags.
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin bench_lmdb_parallel
|
||||
/// ./target/release/bench_lmdb_parallel --procs 4 --iters 5000
|
||||
///
|
||||
/// Env var FFF_BENCH_ROLE=worker turns the binary into a worker that talks
|
||||
/// to a db path passed via FFF_BENCH_DB.
|
||||
use fff::frecency::FrecencyTracker;
|
||||
use fff::query_tracker::QueryTracker;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Instant;
|
||||
|
||||
fn parse_args() -> (usize, usize, Option<String>) {
|
||||
let mut procs = 4usize;
|
||||
let mut iters = 2000usize;
|
||||
let mut db_path: Option<String> = None;
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--procs" => procs = args.next().and_then(|s| s.parse().ok()).unwrap_or(procs),
|
||||
"--iters" => iters = args.next().and_then(|s| s.parse().ok()).unwrap_or(iters),
|
||||
"--db" => db_path = args.next(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(procs, iters, db_path)
|
||||
}
|
||||
|
||||
fn worker_main(db: &Path, iters: usize, worker_id: u32) {
|
||||
let frecency_path = db.join("frecency");
|
||||
let history_path = db.join("history");
|
||||
|
||||
let frecency = FrecencyTracker::open(&frecency_path).expect("frecency open");
|
||||
let mut query_tracker = QueryTracker::open(&history_path).expect("query tracker open");
|
||||
|
||||
let project = PathBuf::from("/bench/project");
|
||||
let started = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
let file = PathBuf::from(format!(
|
||||
"/bench/project/src/file_{}_{}.rs",
|
||||
worker_id,
|
||||
i % 256
|
||||
));
|
||||
// Frecency write path — same call as track_access on BufEnter.
|
||||
frecency.track_access(&file).expect("frecency track_access");
|
||||
|
||||
// Query tracker write path — same as track_query_completion on file open.
|
||||
let query = format!("q{}", i % 32);
|
||||
query_tracker
|
||||
.track_query_completion(&query, &project, &file)
|
||||
.expect("query tracker track_query_completion");
|
||||
|
||||
// A read every few iterations.
|
||||
if i % 8 == 0 {
|
||||
let _ = frecency.seconds_since_last_access(&file).ok();
|
||||
let _ = query_tracker
|
||||
.get_historical_query(&project, 0)
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!(
|
||||
"worker {} done: {} iters in {:?} ({:.0} ops/s)",
|
||||
worker_id,
|
||||
iters,
|
||||
elapsed,
|
||||
iters as f64 * 3.0 / elapsed.as_secs_f64()
|
||||
);
|
||||
}
|
||||
|
||||
fn driver_main(procs: usize, iters: usize, db_override: Option<String>) {
|
||||
let db = db_override.map(PathBuf::from).unwrap_or_else(|| {
|
||||
std::env::temp_dir().join(format!("fff_bench_lmdb_{}", std::process::id()))
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&db);
|
||||
std::fs::create_dir_all(&db).expect("create db dir");
|
||||
|
||||
let exe = env::current_exe().expect("current_exe");
|
||||
|
||||
eprintln!(
|
||||
"driver: launching {} workers, {} iters each, db={}",
|
||||
procs,
|
||||
iters,
|
||||
db.display()
|
||||
);
|
||||
|
||||
let started = Instant::now();
|
||||
let mut children = Vec::with_capacity(procs);
|
||||
for worker_id in 0..procs {
|
||||
let child = Command::new(&exe)
|
||||
.env("FFF_BENCH_ROLE", "worker")
|
||||
.env("FFF_BENCH_DB", &db)
|
||||
.env("FFF_BENCH_ITERS", iters.to_string())
|
||||
.env("FFF_BENCH_WORKER_ID", worker_id.to_string())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("spawn worker");
|
||||
children.push(child);
|
||||
}
|
||||
|
||||
let mut failures = 0u32;
|
||||
for mut child in children {
|
||||
let status = child.wait().expect("wait");
|
||||
if !status.success() {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
let total_ops = procs as f64 * iters as f64 * 3.0;
|
||||
eprintln!(
|
||||
"driver: all workers done in {:?}, ~{:.0} ops/s aggregate, failures={}",
|
||||
elapsed,
|
||||
total_ops / elapsed.as_secs_f64(),
|
||||
failures
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&db);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Worker branch: spawned by driver to contend on the same db.
|
||||
if env::var("FFF_BENCH_ROLE").as_deref() == Ok("worker") {
|
||||
let db = env::var("FFF_BENCH_DB").expect("FFF_BENCH_DB");
|
||||
let iters: usize = env::var("FFF_BENCH_ITERS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1000);
|
||||
let worker_id: u32 = env::var("FFF_BENCH_WORKER_ID")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
worker_main(Path::new(&db), iters, worker_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let (procs, iters, db) = parse_args();
|
||||
driver_main(procs, iters, db);
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use std::time::Instant;
|
||||
|
||||
fn load_picker(path: &std::path::Path) -> FilePicker {
|
||||
let mut picker = FilePicker::new(fff::FilePickerOptions {
|
||||
base_path: path.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
mode: fff::FFFMode::Neovim,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to create FilePicker");
|
||||
picker.collect_files().expect("Failed to collect files");
|
||||
picker
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let big_repo_path = std::path::PathBuf::from("./big-repo");
|
||||
|
||||
@@ -18,47 +30,11 @@ fn main() {
|
||||
|
||||
eprintln!("Loading files from: {:?}", canonical_path);
|
||||
|
||||
// Directly scan without background thread
|
||||
let start = Instant::now();
|
||||
let files = {
|
||||
use ignore::WalkBuilder;
|
||||
let mut files = Vec::new();
|
||||
|
||||
WalkBuilder::new(&canonical_path)
|
||||
.hidden(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().to_path_buf();
|
||||
let relative =
|
||||
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
|
||||
|
||||
let relative_path = relative.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_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
};
|
||||
|
||||
let picker = load_picker(&canonical_path);
|
||||
eprintln!(
|
||||
"✓ Loaded {} files in {:.2}s\n",
|
||||
files.len(),
|
||||
picker.get_files().len(),
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
@@ -69,7 +45,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),
|
||||
@@ -90,8 +65,7 @@ fn main() {
|
||||
for _ in 0..iterations {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let results = FilePicker::fuzzy_search(
|
||||
&files,
|
||||
let results = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use fff::FileItem;
|
||||
/// Fuzzy grep quality test against ~/dev/lightsource
|
||||
///
|
||||
/// Runs queries through the fuzzy grep pipeline and prints results
|
||||
@@ -7,91 +6,42 @@ use fff::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::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use std::io::Read;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
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().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 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_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
is_binary,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
fn create_picker(path: &Path) -> FilePicker {
|
||||
let mut picker = FilePicker::new(fff::FilePickerOptions {
|
||||
base_path: path.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
mode: fff::FFFMode::Neovim,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to create FilePicker");
|
||||
picker.collect_files().expect("Failed to collect files");
|
||||
picker
|
||||
}
|
||||
|
||||
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 run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
fn run_fuzzy_query(picker: &FilePicker, query: &str, label: &str) {
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 100, // Get plenty of results
|
||||
page_limit: 100,
|
||||
mode: GrepMode::Fuzzy,
|
||||
time_budget_ms: 0, // No time limit — search all files
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(
|
||||
files,
|
||||
&parsed,
|
||||
&options,
|
||||
&fff::ContentCacheBudget::zero(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let result = picker.grep(&parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
eprintln!("══════════════════════════════════════════════════════════════");
|
||||
@@ -115,7 +65,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(picker));
|
||||
}
|
||||
|
||||
// Truncate long lines for display
|
||||
@@ -169,10 +119,28 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
let repo_path = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/Users/neogoose".to_string()),
|
||||
)
|
||||
.join("dev/lightsource");
|
||||
let (repo_path, queries) = if let Some(idx) = args.iter().position(|a| a == "--path") {
|
||||
let path = args
|
||||
.get(idx + 1)
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("--path requires an argument");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let queries: Vec<String> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != idx && *i != idx + 1)
|
||||
.map(|(_, s)| s.clone())
|
||||
.collect();
|
||||
(path, queries)
|
||||
} else {
|
||||
let path = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/Users/neogoose".to_string()),
|
||||
)
|
||||
.join("dev/lightsource");
|
||||
(path, args)
|
||||
};
|
||||
|
||||
if !repo_path.exists() {
|
||||
eprintln!("Repository not found at: {:?}", repo_path);
|
||||
@@ -185,7 +153,8 @@ fn main() {
|
||||
|
||||
eprintln!("Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let picker = create_picker(&canonical);
|
||||
let files = picker.get_files();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(
|
||||
"Loaded {} files ({} non-binary) in {:.2}s\n",
|
||||
@@ -194,19 +163,19 @@ fn main() {
|
||||
load_start.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
if args.is_empty() {
|
||||
if queries.is_empty() {
|
||||
// Run default test queries
|
||||
run_fuzzy_query(&files, "shcema", "transposition of 'schema'");
|
||||
run_fuzzy_query(&files, "SortedMap", "should match SortedArrayMap");
|
||||
run_fuzzy_query(&picker, "shcema", "transposition of 'schema'");
|
||||
run_fuzzy_query(&picker, "SortedMap", "should match SortedArrayMap");
|
||||
run_fuzzy_query(
|
||||
&files,
|
||||
&picker,
|
||||
"struct SortedMap",
|
||||
"should NOT match SourcingProjectMetadataParts",
|
||||
);
|
||||
} else {
|
||||
// Run user-provided queries
|
||||
for query in &args {
|
||||
run_fuzzy_query(&files, query, "user query");
|
||||
for query in &queries {
|
||||
run_fuzzy_query(&picker, query, "user query");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,73 +4,25 @@
|
||||
/// Measures cold-cache, warm-cache, and incremental typing latencies to simulate
|
||||
/// real user interaction patterns.
|
||||
///
|
||||
/// Uses direct WalkBuilder scanning (no background thread) for faster startup.
|
||||
/// Uses FilePicker::collect_files for synchronous scanning (no background thread).
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin grep_profiler
|
||||
/// ./target/release/grep_profiler [--path /path/to/repo]
|
||||
use fff::{
|
||||
BigramFilter, FileItem,
|
||||
grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query},
|
||||
types::ContentCacheBudget,
|
||||
};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
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().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 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_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
is_binary,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
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 create_picker(path: &std::path::Path) -> FilePicker {
|
||||
let mut picker = FilePicker::new(fff::FilePickerOptions {
|
||||
base_path: path.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
mode: fff::FFFMode::Neovim,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to create FilePicker");
|
||||
picker.collect_files().expect("Failed to collect files");
|
||||
picker
|
||||
}
|
||||
|
||||
struct BenchStats {
|
||||
@@ -121,20 +73,18 @@ impl BenchStats {
|
||||
}
|
||||
|
||||
struct GrepBench<'a> {
|
||||
files: &'a [FileItem],
|
||||
picker: &'a FilePicker,
|
||||
options: GrepSearchOptions,
|
||||
bigram_index: Option<&'a BigramFilter>,
|
||||
}
|
||||
|
||||
impl<'a> GrepBench<'a> {
|
||||
fn new(files: &'a [FileItem]) -> Self {
|
||||
Self::with_mode(files, GrepMode::PlainText)
|
||||
fn new(picker: &'a FilePicker) -> Self {
|
||||
Self::with_mode(picker, GrepMode::PlainText)
|
||||
}
|
||||
|
||||
fn with_mode(files: &'a [FileItem], mode: GrepMode) -> Self {
|
||||
fn with_mode(picker: &'a FilePicker, mode: GrepMode) -> Self {
|
||||
Self {
|
||||
files,
|
||||
bigram_index: None,
|
||||
picker,
|
||||
options: GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
@@ -146,28 +96,17 @@ impl<'a> GrepBench<'a> {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
&parsed,
|
||||
&self.options,
|
||||
&ContentCacheBudget::default(),
|
||||
self.bigram_index,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let result = self.picker.grep(&parsed, &self.options);
|
||||
let elapsed = start.elapsed();
|
||||
(elapsed, result.matches.len(), result.total_files_searched)
|
||||
}
|
||||
@@ -189,17 +128,6 @@ 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 {
|
||||
@@ -263,8 +191,9 @@ fn main() {
|
||||
// Direct file loading (no background thread)
|
||||
eprintln!("\n[1/7] Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let mut files = load_files(&canonical);
|
||||
let picker = create_picker(&canonical);
|
||||
let load_time = load_start.elapsed();
|
||||
let files = picker.get_files();
|
||||
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!(
|
||||
@@ -275,7 +204,7 @@ fn main() {
|
||||
large_files,
|
||||
);
|
||||
|
||||
let bench = GrepBench::new(&files);
|
||||
let bench = GrepBench::new(&picker);
|
||||
|
||||
eprintln!("[2/7] Cold cache benchmarks (first search, mmap not yet loaded)");
|
||||
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
|
||||
@@ -294,8 +223,8 @@ fn main() {
|
||||
|
||||
for (name, query) in &cold_queries {
|
||||
// Re-load files to get fresh FileItems with no cached mmaps
|
||||
let fresh_files = load_files(&canonical);
|
||||
let fresh_bench = GrepBench::new(&fresh_files);
|
||||
let fresh_picker = create_picker(&canonical);
|
||||
let fresh_bench = GrepBench::new(&fresh_picker);
|
||||
let (stats, matches, files_searched) = fresh_bench.bench_query(query, 1);
|
||||
print_row(name, &stats, matches, files_searched, 1);
|
||||
}
|
||||
@@ -334,32 +263,15 @@ fn main() {
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// Bigram-related benchmarks are omitted: the bigram index is built
|
||||
// asynchronously by the picker and is exercised through picker.grep().
|
||||
|
||||
// ── 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_bench = GrepBench::with_mode(&picker, GrepMode::Fuzzy);
|
||||
|
||||
let fuzzy_queries: Vec<(&str, &str, usize)> = vec![
|
||||
("fuzzy_exact", "mutex_lock", 15),
|
||||
@@ -434,7 +346,7 @@ fn main() {
|
||||
eprintln!("[6/7] Incremental typing simulation (plain text)");
|
||||
eprintln!(" Simulates user typing character by character.\n");
|
||||
|
||||
let bench = GrepBench::new(&files);
|
||||
let bench = GrepBench::new(&picker);
|
||||
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
@@ -505,17 +417,12 @@ fn main() {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&opts,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let result = picker.grep(&parsed, &opts);
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
|
||||
@@ -534,14 +441,6 @@ fn main() {
|
||||
}
|
||||
|
||||
eprintln!("\n=== Summary ===");
|
||||
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);
|
||||
eprintln!(" Files > 10MB (skipped): {}", large_files);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use fff::FFFQuery;
|
||||
use fff::FileItem;
|
||||
use fff::file_picker::FilePicker;
|
||||
/// FFF vs ripgrep comparison benchmark
|
||||
///
|
||||
/// Demonstrates why a persistent in-process search engine (fff) is fundamentally
|
||||
@@ -21,8 +20,7 @@ use fff::FileItem;
|
||||
/// Usage:
|
||||
/// cargo build --release --bin grep_vs_rg
|
||||
/// ./target/release/grep_vs_rg [--path /path/to/repo] [--iters 5]
|
||||
use fff::grep::{GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use std::io::Read;
|
||||
use fff::grep::{GrepSearchOptions, parse_grep_query};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -30,57 +28,16 @@ use std::time::{Duration, Instant};
|
||||
/// Number of times each query is repeated (overridable with --iters).
|
||||
const DEFAULT_ITERS: usize = 5;
|
||||
|
||||
fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut files = Vec::new();
|
||||
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().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 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_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
is_binary,
|
||||
));
|
||||
});
|
||||
files
|
||||
}
|
||||
|
||||
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 create_picker(path: &Path) -> FilePicker {
|
||||
let mut picker = FilePicker::new(fff::FilePickerOptions {
|
||||
base_path: path.to_string_lossy().to_string(),
|
||||
enable_mmap_cache: false,
|
||||
mode: fff::FFFMode::Neovim,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to create FilePicker");
|
||||
picker.collect_files().expect("Failed to collect files");
|
||||
picker
|
||||
}
|
||||
|
||||
/// Telescope's default vimgrep_arguments applied to any rg command.
|
||||
@@ -186,7 +143,7 @@ fn run_rg_page(
|
||||
}
|
||||
}
|
||||
|
||||
// Kill rg immediately — this is what telescope does when the picker
|
||||
// Kill rg immediately --- this is what telescope does when the picker
|
||||
// closes or the query changes (plenary.job:shutdown).
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -200,7 +157,7 @@ fn bytecount(bytes: &[u8], needle: u8) -> usize {
|
||||
}
|
||||
|
||||
/// fff full: collects all GrepMatch structs (what the UI uses).
|
||||
fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
fn run_fff_full(picker: &FilePicker, query: &str) -> (usize, Duration) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
@@ -213,51 +170,17 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
let start = Instant::now();
|
||||
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 result = picker.grep(&parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
/// fff paginated: first 50 results only (real UI scenario).
|
||||
fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
fn run_fff_page(picker: &FilePicker, query: &str) -> (usize, Duration) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
@@ -270,17 +193,11 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
abort_signal: None,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(
|
||||
files,
|
||||
&parsed,
|
||||
&options,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let result = picker.grep(&parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
@@ -369,13 +286,14 @@ fn main() {
|
||||
eprintln!();
|
||||
|
||||
eprintln!("[1/5] Indexing files...");
|
||||
let files = load_files(&canonical);
|
||||
let picker = create_picker(&canonical);
|
||||
let files = picker.get_files();
|
||||
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)...");
|
||||
for q in &["return", "mutex", "struct", "include", "if", "int"] {
|
||||
let _ = run_fff_page(&files, q);
|
||||
let _ = run_fff_page(&picker, q);
|
||||
let _ = run_rg_count(&canonical, q, true, threads);
|
||||
}
|
||||
eprintln!(" mmap cache: warmed\n");
|
||||
@@ -418,7 +336,7 @@ fn main() {
|
||||
for (name, query, ci) in &queries {
|
||||
let q = *query;
|
||||
let ci = *ci;
|
||||
let fs = run_n(|| run_fff_full(&files, q), iters);
|
||||
let fs = run_n(|| run_fff_full(&picker, q), iters);
|
||||
let rs = run_n(|| run_rg_lines(&canonical, q, ci, threads), iters);
|
||||
|
||||
eprintln!(
|
||||
@@ -446,11 +364,11 @@ fn main() {
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"\n[5/5] First-page latency — the real UI scenario ({} iters, showing min)",
|
||||
"\n[5/5] First-page latency --- the real UI scenario ({} iters, showing min)",
|
||||
iters
|
||||
);
|
||||
eprintln!(" fff: paginated search (50 matches) from warm mmap cache");
|
||||
eprintln!(" rg: telescope-style (spawn, stream 50 lines, kill) — per-keystroke cost\n");
|
||||
eprintln!(" rg: telescope-style (spawn, stream 50 lines, kill) --- per-keystroke cost\n");
|
||||
eprintln!(
|
||||
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
|
||||
"Query", "fff min", "matches", "rg min", "matches", "fff/rg"
|
||||
@@ -466,7 +384,7 @@ fn main() {
|
||||
for (name, query, ci) in &queries {
|
||||
let q = *query;
|
||||
let ci = *ci;
|
||||
let fs = run_n(|| run_fff_page(&files, q), iters);
|
||||
let fs = run_n(|| run_fff_page(&picker, q), iters);
|
||||
let rs = run_n(|| run_rg_page(&canonical, q, ci, 50, threads), iters);
|
||||
|
||||
eprintln!(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency};
|
||||
use std::env;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
@@ -60,7 +60,7 @@ fn format_bytes(bytes: usize) -> String {
|
||||
}
|
||||
|
||||
fn test_search_memory_pattern(
|
||||
shared_picker: &SharedPicker,
|
||||
shared_picker: &SharedFilePicker,
|
||||
name: &str,
|
||||
iterations: usize,
|
||||
query_pattern: impl Fn(usize) -> String,
|
||||
@@ -87,8 +87,7 @@ fn test_search_memory_pattern(
|
||||
if let Some(ref picker) = *guard {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(&query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
let search_result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -178,7 +177,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Initialize FilePicker
|
||||
@@ -188,7 +187,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::{
|
||||
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
|
||||
fn wait_for_scan(shared_picker: &SharedFilePicker, timeout_secs: u64) -> Result<usize, String> {
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
if !shared_picker.wait_for_scan(timeout) {
|
||||
return Err(format!("Scan timed out after {} seconds", timeout_secs));
|
||||
@@ -21,18 +19,6 @@ fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files snapshot from shared state
|
||||
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
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().to_vec())
|
||||
} else {
|
||||
Err("FilePicker not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let big_repo_path = std::path::PathBuf::from("./big-repo");
|
||||
|
||||
@@ -47,7 +33,7 @@ fn main() {
|
||||
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
// Create shared state
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
@@ -56,7 +42,7 @@ fn main() {
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: canonical_path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: false,
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -70,7 +56,8 @@ fn main() {
|
||||
let file_count = wait_for_scan(&shared_picker, 120).expect("Failed to wait for scan");
|
||||
eprintln!("✓ Indexed {} files\n", file_count);
|
||||
|
||||
let files = get_files(&shared_picker).expect("Failed to get files");
|
||||
let picker_guard = shared_picker.read().expect("Failed to acquire read lock");
|
||||
let picker = picker_guard.as_ref().expect("FilePicker not initialized");
|
||||
|
||||
// Test queries representing different search patterns
|
||||
let test_queries = vec![
|
||||
@@ -100,8 +87,7 @@ fn main() {
|
||||
|
||||
for _ in 0..iterations {
|
||||
let parsed = parser.parse(query);
|
||||
let results = FilePicker::fuzzy_search(
|
||||
&files,
|
||||
let results = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::thread;
|
||||
@@ -78,7 +78,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Initialize the file picker
|
||||
@@ -88,7 +88,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -126,14 +126,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// If async scan didn't work, trigger a manual scan
|
||||
if !scan_completed {
|
||||
println!("Triggering manual rescan...");
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
match picker.trigger_rescan(&shared_frecency) {
|
||||
Ok(_) => println!("Manual rescan completed"),
|
||||
Err(e) => println!("Manual rescan failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
let _ = shared_picker.trigger_full_rescan_async(&shared_frecency);
|
||||
}
|
||||
|
||||
let initial_file_count = {
|
||||
@@ -144,7 +137,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(picker));
|
||||
}
|
||||
}
|
||||
files.len()
|
||||
@@ -200,8 +193,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let parsed = parser.parse(query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
let search_result = picker.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::git::format_git_status;
|
||||
use fff::{FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff::{
|
||||
FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
@@ -25,7 +27,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let r = running.clone();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_picker = SharedFilePicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Clone for signal handler
|
||||
@@ -50,7 +52,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
enable_mmap_cache: false,
|
||||
mode: FFFMode::default(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -59,7 +61,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Get initial file count from shared state
|
||||
let initial_count = {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let files = picker.get_files();
|
||||
println!("Initial file count: {}", files.len());
|
||||
|
||||
if !files.is_empty() {
|
||||
@@ -68,7 +71,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(
|
||||
" {}. {} ({})",
|
||||
i + 1,
|
||||
file.relative_path(),
|
||||
file.relative_path(picker),
|
||||
format_git_status(file.git_status)
|
||||
);
|
||||
}
|
||||
@@ -107,10 +110,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Show some recently added files
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let files = picker.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(picker));
|
||||
}
|
||||
} else {
|
||||
let removed = last_count - current_count;
|
||||
@@ -151,11 +155,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if iteration % 40 == 0 {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S");
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let picker_ref = guard.as_ref().unwrap();
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse("rs");
|
||||
let search_results = FilePicker::fuzzy_search(
|
||||
files,
|
||||
let search_results = picker_ref.fuzzy_search(
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
@@ -186,7 +189,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(
|
||||
" {}. {} (score: {})",
|
||||
i + 1,
|
||||
file.relative_path(),
|
||||
file.relative_path(picker_ref),
|
||||
score.total
|
||||
);
|
||||
}
|
||||
|
||||
+178
-131
@@ -5,8 +5,9 @@ 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,
|
||||
DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, GrepConfig,
|
||||
PaginationArgs, QueryParser, Score, SearchResult, SharedFilePicker, SharedFrecency,
|
||||
SharedQueryTracker,
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use mlua::prelude::*;
|
||||
@@ -26,48 +27,42 @@ 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(SharedPicker::default);
|
||||
pub static FILE_PICKER: Lazy<SharedFilePicker> = Lazy::new(SharedFilePicker::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),
|
||||
(frecency_db_path, history_db_path, _use_unsafe_no_lock): (String, String, bool),
|
||||
) -> LuaResult<bool> {
|
||||
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()?);
|
||||
*frecency = Some(FrecencyTracker::open(&frecency_db_path).into_lua_result()?);
|
||||
tracing::info!("Frecency database initialized at {}", frecency_db_path);
|
||||
drop(frecency);
|
||||
|
||||
// Spawn background GC to purge stale entries without blocking startup
|
||||
let _ = FRECENCY.spawn_gc(frecency_db_path, use_unsafe_no_lock);
|
||||
let _ = FRECENCY.spawn_gc(frecency_db_path);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER.write().into_lua_result()?;
|
||||
if query_tracker.is_some() {
|
||||
*query_tracker = None;
|
||||
}
|
||||
|
||||
*query_tracker =
|
||||
Some(QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?);
|
||||
*query_tracker = Some(QueryTracker::open(&history_db_path).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().into_lua_result()?;
|
||||
*frecency = None;
|
||||
Ok(true)
|
||||
Ok(FRECENCY.destroy().into_lua_result()?.is_some())
|
||||
}
|
||||
|
||||
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut query_tracker = QUERY_TRACKER.write().into_lua_result()?;
|
||||
*query_tracker = None;
|
||||
Ok(true)
|
||||
Ok(QUERY_TRACKER.destroy().into_lua_result()?.is_some())
|
||||
}
|
||||
|
||||
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
@@ -83,7 +78,8 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
FRECENCY.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path,
|
||||
warmup_mmap_cache: true,
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -94,19 +90,22 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
// Cancel and stop the old picker under a single write lock to avoid
|
||||
// a window where FILE_PICKER is None (which causes FilePickerMissing
|
||||
// errors if the UI is searching concurrently).
|
||||
// Cancel and stop the old picker's watcher under the write lock.
|
||||
// `stop_background_monitor` is non-blocking (signals the debouncer
|
||||
// to exit on its next tick without joining), so it's safe under
|
||||
// the lock. In-flight watcher handlers finish naturally once we
|
||||
// release the guard.
|
||||
{
|
||||
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.
|
||||
// Signal cancellation BEFORE stopping the watcher so any
|
||||
// orphaned scan/post-scan threads discard their results
|
||||
// instead of racing with the new picker.
|
||||
picker.cancel();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Don't take() here — leave the old picker in place so searches
|
||||
// still work until new_with_shared_state replaces it atomically.
|
||||
// Don't take() the picker here — leave the old one in place so
|
||||
// searches still work until new_with_shared_state replaces it.
|
||||
}
|
||||
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
@@ -115,7 +114,8 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
FRECENCY.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
enable_mmap_cache: true,
|
||||
enable_content_indexing: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -137,14 +137,37 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
|
||||
&& picker.base_path() == canonical_path
|
||||
{
|
||||
return Ok(()); // same dir
|
||||
}
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
// Spawn a background thread BEFORE touching the picker lock. The
|
||||
// same-dir short-circuit previously called `FILE_PICKER.read()` on
|
||||
// the lua/UI thread, which blocks if a reindex writer is already in
|
||||
// flight — on repeated DirChanged events (e.g. LSP root switching
|
||||
// right after closing the picker) that could freeze the nvim main
|
||||
// loop for the entire duration of an in-progress scan. Move the
|
||||
// check into the spawned worker so the main thread always returns
|
||||
// instantly; the internal reinit path also re-checks and no-ops if
|
||||
// the picker is already pointing at `canonical_path`.
|
||||
std::thread::spawn(move || {
|
||||
::tracing::info!(
|
||||
?canonical_path,
|
||||
"restart_index_in_path: spawned worker running"
|
||||
);
|
||||
{
|
||||
let guard = match FILE_PICKER.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Some(ref picker) = *guard
|
||||
&& picker.base_path() == canonical_path
|
||||
{
|
||||
::tracing::info!(?canonical_path, "restart_index_in_path: same dir, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
::tracing::info!(
|
||||
?canonical_path,
|
||||
"restart_index_in_path: calling reinit_file_picker_internal"
|
||||
);
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
::tracing::error!(
|
||||
?e,
|
||||
@@ -160,14 +183,13 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
// Async: spawns a BG thread that walks off-lock, swaps sync_data
|
||||
// under a brief write, then applies git + frecency off-lock. Returns
|
||||
// immediately so the lua/UI thread never waits on the walk.
|
||||
FILE_PICKER
|
||||
.trigger_full_rescan_async(&FRECENCY)
|
||||
.into_lua_result()?;
|
||||
|
||||
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
::tracing::info!("scan_files rescan spawned");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -218,9 +240,7 @@ pub fn fuzzy_search_files(
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
let parsed = parser.parse(&query);
|
||||
|
||||
let files = picker.get_files();
|
||||
let results = FilePicker::fuzzy_search(
|
||||
files,
|
||||
let results = picker.fuzzy_search(
|
||||
&parsed,
|
||||
query_tracker_guard.as_ref(),
|
||||
FuzzySearchOptions {
|
||||
@@ -244,9 +264,9 @@ pub fn fuzzy_search_files(
|
||||
|
||||
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)) {
|
||||
if let Some(found_file) = picker.get_file_by_path(&path) {
|
||||
let found = SearchResult {
|
||||
items: vec![&files[idx]],
|
||||
items: vec![found_file],
|
||||
scores: vec![Score {
|
||||
exact_match: true,
|
||||
match_type: "path",
|
||||
@@ -257,14 +277,14 @@ pub fn fuzzy_search_files(
|
||||
location: parsed.location,
|
||||
};
|
||||
|
||||
return lua_types::SearchResultLua::from(found).into_lua(lua);
|
||||
return lua_types::SearchResultLua::new(found, picker).into_lua(lua);
|
||||
}
|
||||
|
||||
return build_file_path_fallback(lua, &path, results.total_files);
|
||||
}
|
||||
}
|
||||
|
||||
lua_types::SearchResultLua::from(results).into_lua(lua)
|
||||
lua_types::SearchResultLua::new(results, picker).into_lua(lua)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -279,6 +299,7 @@ pub fn live_grep(
|
||||
smart_case,
|
||||
grep_mode,
|
||||
time_budget_ms,
|
||||
trim_whitespace,
|
||||
): (
|
||||
String,
|
||||
Option<usize>,
|
||||
@@ -288,6 +309,7 @@ pub fn live_grep(
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
Option<u64>,
|
||||
Option<bool>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
|
||||
@@ -313,10 +335,12 @@ pub fn live_grep(
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: trim_whitespace.unwrap_or(false),
|
||||
abort_signal: None,
|
||||
};
|
||||
|
||||
let result = picker.grep(&parsed, &options);
|
||||
lua_types::GrepResultLua::from(result).into_lua(lua)
|
||||
lua_types::GrepResultLua::new(result, picker).into_lua(lua)
|
||||
}
|
||||
|
||||
/// Build a file-picker result for an absolute path that exists on disk but
|
||||
@@ -331,7 +355,6 @@ fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaRe
|
||||
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))?;
|
||||
@@ -370,32 +393,57 @@ fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaRe
|
||||
}
|
||||
|
||||
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
// must be async and capture a local copy of the path because the write lock
|
||||
// is unsafe and can be held forever which is unavoidable, but at least we can
|
||||
// prevent users from deadlock of the main thread if someone deletes a lock
|
||||
let file_path = PathBuf::from(&file_path);
|
||||
std::thread::spawn(move || {
|
||||
{
|
||||
let frecency_guard = match FRECENCY.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
::tracing::debug!(?e, "track_access: frecency read lock poisoned");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = frecency.track_access(file_path.as_path()) {
|
||||
::tracing::debug!(?e, ?file_path, "track_access: frecency DB write failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
frecency
|
||||
.track_access(file_path.as_path())
|
||||
.into_lua_result()?;
|
||||
drop(frecency_guard);
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
::tracing::debug!(?e, "track_access: file picker write lock poisoned");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Quick lock to update single file's frecency score in picker
|
||||
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().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker
|
||||
.update_single_file_frecency(&file_path, frecency)
|
||||
.into_lua_result()?;
|
||||
let frecency_guard = match FRECENCY.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
::tracing::debug!(?e, "track_access: frecency read lock poisoned on update");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = picker.update_single_file_frecency(&file_path, frecency) {
|
||||
::tracing::debug!(
|
||||
?e,
|
||||
?file_path,
|
||||
"track_access: update_single_file_frecency failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -432,6 +480,15 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
Ok(picker.git_root().map(|p| p.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
pub fn get_base_path(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(picker.base_path().to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FILE_PICKER.refresh_git_status(&FRECENCY).into_lua_result()
|
||||
}
|
||||
@@ -454,13 +511,14 @@ pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
// `stop_background_monitor` is non-blocking — the debouncer /
|
||||
// owner threads exit on their next iteration, so it is safe to
|
||||
// call under the FILE_PICKER write lock.
|
||||
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));
|
||||
};
|
||||
|
||||
picker.stop_background_monitor();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -481,27 +539,30 @@ 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().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
// Canonicalize the file path before spawning thread
|
||||
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");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
// Everything runs on a background thread — including the
|
||||
// `FILE_PICKER.read()` used to fetch `project_path`. Doing that read
|
||||
// on the main (UI) thread stalled nvim whenever a reindex writer was
|
||||
// in flight: parking_lot's fair queue makes a read block behind a
|
||||
// pending writer, so the main thread would freeze for the full
|
||||
// duration of the scan.
|
||||
let query_tracker = QUERY_TRACKER.clone();
|
||||
std::thread::spawn(move || {
|
||||
let project_path = match FILE_PICKER.read() {
|
||||
Ok(guard) => match *guard {
|
||||
Some(ref picker) => picker.base_path().to_path_buf(),
|
||||
None => return,
|
||||
},
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
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");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -538,16 +599,18 @@ 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().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
// Move the `FILE_PICKER.read()` into the spawned worker too —
|
||||
// see the note on `track_query_completion` above for why.
|
||||
let query_tracker = QUERY_TRACKER.clone();
|
||||
std::thread::spawn(move || {
|
||||
let project_path = match FILE_PICKER.read() {
|
||||
Ok(guard) => match *guard {
|
||||
Some(ref picker) => picker.base_path().to_path_buf(),
|
||||
None => return,
|
||||
},
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Ok(mut guard) = query_tracker.write()
|
||||
&& let Some(ref mut tracker) = *guard
|
||||
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
|
||||
@@ -582,40 +645,22 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str
|
||||
.into_lua_result()
|
||||
}
|
||||
|
||||
/// Parse a grep query string and return its text portion (with constraints stripped).
|
||||
///
|
||||
/// Uses the Rust `GrepConfig` parser as the single source of truth, so Lua
|
||||
/// code never needs to re-implement constraint detection.
|
||||
pub fn parse_grep_query(lua: &Lua, query: String) -> LuaResult<LuaTable> {
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let parsed = parser.parse(&query);
|
||||
let table = lua.create_table()?;
|
||||
table.set("grep_text", parsed.grep_text())?;
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
// Extract the scan signal Arc WITHOUT holding the read lock, so the
|
||||
// scan thread can acquire the write lock to store its results.
|
||||
// Holding a read lock while polling would deadlock: the scan thread
|
||||
// needs a write lock to finish, but can't acquire it while we hold the read lock.
|
||||
let scan_signal = {
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
picker.scan_signal()
|
||||
}; // read lock released here
|
||||
let scanned = FILE_PICKER.wait_for_scan(Duration::from_millis(timeout_ms.unwrap_or(500)));
|
||||
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
std::thread::sleep(sleep_duration);
|
||||
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
::tracing::debug!(
|
||||
"wait_for_initial_scan completed in {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
Ok(true)
|
||||
Ok(scanned)
|
||||
}
|
||||
|
||||
pub fn init_tracing(
|
||||
@@ -792,6 +837,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
lua.create_function(refresh_git_status)?,
|
||||
)?;
|
||||
exports.set("get_git_root", lua.create_function(get_git_root)?)?;
|
||||
exports.set("get_base_path", lua.create_function(get_base_path)?)?;
|
||||
exports.set(
|
||||
"stop_background_monitor",
|
||||
lua.create_function(stop_background_monitor)?,
|
||||
@@ -822,6 +868,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)?)?;
|
||||
exports.set("parse_grep_query", lua.create_function(parse_grep_query)?)?;
|
||||
|
||||
Ok(exports)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::git::format_git_status;
|
||||
use fff::{FileItem, GrepResult, Location, Score, SearchResult};
|
||||
use mlua::prelude::*;
|
||||
|
||||
pub struct SearchResultLua<'a> {
|
||||
inner: SearchResult<'a>,
|
||||
picker: &'a FilePicker,
|
||||
}
|
||||
|
||||
impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
|
||||
fn from(inner: SearchResult<'a>) -> Self {
|
||||
Self { inner }
|
||||
impl<'a> SearchResultLua<'a> {
|
||||
pub fn new(inner: SearchResult<'a>, picker: &'a FilePicker) -> Self {
|
||||
Self { inner, picker }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GrepResultLua<'a> {
|
||||
inner: GrepResult<'a>,
|
||||
picker: &'a FilePicker,
|
||||
}
|
||||
|
||||
impl<'a> From<GrepResult<'a>> for GrepResultLua<'a> {
|
||||
fn from(inner: GrepResult<'a>) -> Self {
|
||||
Self { inner }
|
||||
impl<'a> GrepResultLua<'a> {
|
||||
pub fn new(inner: GrepResult<'a>, picker: &'a FilePicker) -> Self {
|
||||
Self { inner, picker }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +36,10 @@ impl IntoLua for LuaPosition {
|
||||
}
|
||||
}
|
||||
|
||||
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
fn file_item_into_lua(item: &FileItem, lua: &Lua, picker: &FilePicker) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("path", item.path_str())?;
|
||||
table.set("relative_path", item.relative_path())?;
|
||||
table.set("name", item.file_name())?;
|
||||
table.set("relative_path", item.relative_path(picker))?;
|
||||
table.set("name", item.file_name(picker))?;
|
||||
table.set("size", item.size)?;
|
||||
table.set("modified", item.modified)?;
|
||||
table.set("access_frecency_score", item.access_frecency_score)?;
|
||||
@@ -61,6 +63,7 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
table.set("distance_penalty", score.distance_penalty)?;
|
||||
table.set("current_file_penalty", score.current_file_penalty)?;
|
||||
table.set("combo_match_boost", score.combo_match_boost)?;
|
||||
table.set("path_alignment_bonus", score.path_alignment_bonus)?;
|
||||
table.set("match_type", score.match_type)?;
|
||||
table.set("exact_match", score.exact_match)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
@@ -73,7 +76,7 @@ impl IntoLua for SearchResultLua<'_> {
|
||||
// Convert items
|
||||
let items_table = lua.create_table()?;
|
||||
for (i, item) in self.inner.items.iter().enumerate() {
|
||||
items_table.set(i + 1, file_item_into_lua(item, lua)?)?;
|
||||
items_table.set(i + 1, file_item_into_lua(item, lua, self.picker)?)?;
|
||||
}
|
||||
table.set("items", items_table)?;
|
||||
|
||||
@@ -122,9 +125,8 @@ impl IntoLua for GrepResultLua<'_> {
|
||||
|
||||
// File metadata from the deduplicated files vec
|
||||
let file = self.inner.files[m.file_index];
|
||||
item.set("path", file.path_str())?;
|
||||
item.set("relative_path", file.relative_path())?;
|
||||
item.set("name", file.file_name())?;
|
||||
item.set("relative_path", file.relative_path(self.picker))?;
|
||||
item.set("name", file.file_name(self.picker))?;
|
||||
item.set("is_binary", file.is_binary())?;
|
||||
item.set("git_status", format_git_status(file.git_status))?;
|
||||
item.set("size", file.size)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-query-parser"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
description = "Query parser for fff file finder - includes specific syntax for various constraints like globs, extensions, regex etc"
|
||||
license = "MIT"
|
||||
|
||||
@@ -91,6 +91,12 @@ pub trait ParserConfig {
|
||||
has_wildcards(token)
|
||||
}
|
||||
|
||||
/// If `true`, a PathSegment constraint that is the ONLY token in the
|
||||
/// query is demoted to fuzzy text to avoid over filtering
|
||||
fn treat_lone_path_as_text(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Custom constraint parsers for picker-specific needs
|
||||
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
|
||||
None
|
||||
@@ -187,6 +193,15 @@ impl ParserConfig for GrepConfig {
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AiGrepConfig;
|
||||
|
||||
/// Configuration for directory and mixed search modes.
|
||||
///
|
||||
/// Disables path segment parsing so that trailing `/` is kept as fuzzy text
|
||||
/// (e.g. `fff-core/` fuzzy-matches directory paths instead of becoming a
|
||||
/// `PathSegment("fff-core")` constraint with an empty query). Extension and
|
||||
/// filename constraints are also disabled since they don't apply to directories.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct DirSearchConfig;
|
||||
|
||||
impl ParserConfig for AiGrepConfig {
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
@@ -232,3 +247,35 @@ impl ParserConfig for AiGrepConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParserConfig for DirSearchConfig {
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_extension(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_type_filter(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_git_status(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for mixed (files + directories) search.
|
||||
///
|
||||
/// Like `DirSearchConfig`, disables path segment parsing so trailing `/`
|
||||
/// triggers dirs-only mode instead of becoming a constraint. Keeps git
|
||||
/// status and extension filters enabled since files are part of the results.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MixedSearchConfig;
|
||||
|
||||
impl ParserConfig for MixedSearchConfig {
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ pub mod glob_detect;
|
||||
pub mod location;
|
||||
mod parser;
|
||||
|
||||
pub use config::{AiGrepConfig, FileSearchConfig, GrepConfig, ParserConfig};
|
||||
pub use config::{
|
||||
AiGrepConfig, DirSearchConfig, FileSearchConfig, GrepConfig, MixedSearchConfig, ParserConfig,
|
||||
};
|
||||
pub use constraints::{Constraint, GitStatusFilter};
|
||||
pub use location::Location;
|
||||
pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
|
||||
|
||||
@@ -61,7 +61,15 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
.rev()
|
||||
.take_while(|&b| b != b':')
|
||||
.all(|b| b.is_ascii_digit());
|
||||
if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {
|
||||
|
||||
// for grep we don't want to treat a part of path like pathname
|
||||
let treat_as_text = matches!(constraint, Constraint::PathSegment(_))
|
||||
&& config.treat_lone_path_as_text();
|
||||
|
||||
if !matches!(constraint, Constraint::FilePath(_))
|
||||
&& !has_location_suffix
|
||||
&& !treat_as_text
|
||||
{
|
||||
constraints.push(constraint);
|
||||
return FFFQuery {
|
||||
raw_query,
|
||||
@@ -102,6 +110,10 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
let tokens = query.split_whitespace();
|
||||
|
||||
let mut has_file_path = false;
|
||||
// Track the FilePath token position in constraints so we can promote
|
||||
// it back to text if the final query ends up with no fuzzy text.
|
||||
let mut file_path_constraint_idx: Option<usize> = None;
|
||||
let mut file_path_token: Option<&str> = None;
|
||||
for token in tokens {
|
||||
match parse_token(token, config) {
|
||||
Some(Constraint::FilePath(_)) => {
|
||||
@@ -111,6 +123,8 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
// searching for).
|
||||
text_parts.push(token);
|
||||
} else {
|
||||
file_path_constraint_idx = Some(constraints.len());
|
||||
file_path_token = Some(token);
|
||||
constraints.push(Constraint::FilePath(token));
|
||||
has_file_path = true;
|
||||
}
|
||||
@@ -124,6 +138,21 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
}
|
||||
}
|
||||
|
||||
// If the query produced a single FilePath and no fuzzy text parts, the
|
||||
// user isn't filtering by filename suffix — they're fuzzy-searching
|
||||
// for that name (the only other constraints are path-scoping like
|
||||
// PathSegment/Extension/Glob). Mirror the single-token rule at
|
||||
// parser.rs:48-64: promote FilePath → fuzzy text so e.g. `profile.h`
|
||||
// alongside `chrome/browser/profiles/` fuzzy-matches all `profile*.h`
|
||||
// files instead of only one file literally ending in `/profile.h`.
|
||||
if text_parts.is_empty()
|
||||
&& let Some(idx) = file_path_constraint_idx
|
||||
&& let Some(tok) = file_path_token
|
||||
{
|
||||
constraints.remove(idx);
|
||||
text_parts.push(tok);
|
||||
}
|
||||
|
||||
// Try to extract location from the last fuzzy token
|
||||
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
|
||||
let location = if config.enable_location() && !text_parts.is_empty() {
|
||||
@@ -174,8 +203,8 @@ impl<'a> FFFQuery<'a> {
|
||||
/// with the leading `\` stripped, since the backslash is only an escape
|
||||
/// signal to the parser and should not appear in the final pattern.
|
||||
///
|
||||
/// `FuzzyQuery::Empty` → empty string
|
||||
/// `FuzzyQuery::Text("foo")` → `"foo"`
|
||||
/// `FuzzyQuery::Empty` → empty string
|
||||
/// `FuzzyQuery::Text("foo")` → `"foo"`
|
||||
/// `FuzzyQuery::Parts(["a", "\\*.rs", "b"])` → `"a *.rs b"`
|
||||
pub fn grep_text(&self) -> String {
|
||||
match &self.fuzzy_query {
|
||||
@@ -365,7 +394,7 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
"type" if config.enable_type_filter() => {
|
||||
return Some(Constraint::FileType(value));
|
||||
}
|
||||
"status" | "gi" | "g" | "st" if config.enable_git_status() => {
|
||||
"status" | "st" | "g" | "git" if config.enable_git_status() => {
|
||||
return parse_git_status(value);
|
||||
}
|
||||
_ => {}
|
||||
@@ -586,6 +615,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negation_git_status_all_key_aliases() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
for key in ["status", "st", "g", "git"] {
|
||||
let query = format!("!{key}:modified foo");
|
||||
let result = parser.parse(&query);
|
||||
assert_eq!(
|
||||
result.constraints.len(),
|
||||
1,
|
||||
"!{key}:modified should produce exactly one constraint"
|
||||
);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => assert!(
|
||||
matches!(**inner, Constraint::GitStatus(GitStatusFilter::Modified)),
|
||||
"!{key}:modified expected Not(GitStatus(Modified)), got Not({inner:?})"
|
||||
),
|
||||
other => {
|
||||
panic!("!{key}:modified expected Not(GitStatus), got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backslash_escape_extension() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
@@ -893,6 +945,130 @@ mod tests {
|
||||
assert_eq!(result.grep_text(), "pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
|
||||
// When the ONLY non-text constraints are path-scoping (PathSegment,
|
||||
// here), a bare filename token like `profile.h` should NOT be used as
|
||||
// a FilePath filter — the user is fuzzy-searching within that dir,
|
||||
// not asking for files named exactly `profile.h`.
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("chrome/browser/profiles/ profile.h");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(
|
||||
result.constraints[0],
|
||||
Constraint::PathSegment("chrome/browser/profiles")
|
||||
),
|
||||
"Expected single PathSegment, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
assert_eq!(result.grep_text(), "profile.h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_leading_slash_path_alone_is_text_not_path_segment() {
|
||||
// A leading-slash multi-segment path like `/api/tests/` or `/api/tests`
|
||||
// used as the sole query token should be treated as fuzzy text, NOT as
|
||||
// a PathSegment constraint. The user is searching for files matching
|
||||
// that path string, not trying to scope results to a directory.
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
|
||||
// With trailing slash
|
||||
let result = parser.parse("/api/tests/");
|
||||
assert_eq!(
|
||||
result.constraints.len(),
|
||||
0,
|
||||
"Expected no constraints for '/api/tests/', got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
assert!(
|
||||
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
|
||||
"Expected FuzzyQuery::Text, got {:?}",
|
||||
result.fuzzy_query
|
||||
);
|
||||
|
||||
// Without trailing slash
|
||||
let result = parser.parse("/api/tests");
|
||||
assert_eq!(
|
||||
result.constraints.len(),
|
||||
0,
|
||||
"Expected no constraints for '/api/tests', got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
assert!(
|
||||
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
|
||||
"Expected FuzzyQuery::Text, got {:?}",
|
||||
result.fuzzy_query
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_leading_slash_path_alone_is_text_not_path_segment() {
|
||||
// Same behavior for regular GrepConfig — single-token path-like
|
||||
// queries are search terms, not directory filters.
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
|
||||
let result = parser.parse("/api/tests/");
|
||||
assert_eq!(
|
||||
result.constraints.len(),
|
||||
0,
|
||||
"GrepConfig: expected no constraints for '/api/tests/', got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
assert!(
|
||||
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
|
||||
"GrepConfig: expected FuzzyQuery::Text, got {:?}",
|
||||
result.fuzzy_query
|
||||
);
|
||||
|
||||
let result = parser.parse("/api/tests");
|
||||
assert_eq!(
|
||||
result.constraints.len(),
|
||||
0,
|
||||
"GrepConfig: expected no constraints for '/api/tests', got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
assert!(
|
||||
matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
|
||||
"GrepConfig: expected FuzzyQuery::Text, got {:?}",
|
||||
result.fuzzy_query
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_filename_with_extension_only_promotes_to_text() {
|
||||
// Same case with an Extension constraint — no fuzzy text means the
|
||||
// filename is what the user is searching for.
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("*.h profile.h");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::Extension("h")),
|
||||
"Expected Extension, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
assert_eq!(result.grep_text(), "profile.h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_filename_with_other_text_keeps_filepath() {
|
||||
// Sanity: when there IS fuzzy text alongside the filename, the
|
||||
// filename stays a FilePath filter (the documented multi-token case).
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("main.rs pattern");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
|
||||
"Expected FilePath, got {:?}",
|
||||
result.constraints
|
||||
);
|
||||
assert_eq!(result.grep_text(), "pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_filename_schema_rs() {
|
||||
use crate::AiGrepConfig;
|
||||
@@ -1134,16 +1310,16 @@ mod tests {
|
||||
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);
|
||||
// With only path-scoping constraints (Extension) and no fuzzy text,
|
||||
// `main.rs` is promoted to fuzzy text — the user is fuzzy-searching
|
||||
// for "main.rs" among `.lua` files, not filtering by literal filename
|
||||
// suffix. Only the Extension constraint remains.
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FilePath("main.rs")
|
||||
));
|
||||
assert!(matches!(
|
||||
result.constraints[1],
|
||||
Constraint::Extension("lua")
|
||||
));
|
||||
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+532
-442
File diff suppressed because it is too large
Load Diff
Generated
+59
-4
@@ -15,6 +15,22 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696426674,
|
||||
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
@@ -35,11 +51,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1773628058,
|
||||
"narHash": "sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY=",
|
||||
"lastModified": 1776329215,
|
||||
"narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f8573b9c935cfaa162dd62cc9e75ae2db86f85df",
|
||||
"rev": "b86751bc4085f48661017fa226dee99fab6c651b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -54,7 +70,8 @@
|
||||
"crane": "crane",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
"rust-overlay": "rust-overlay",
|
||||
"zig-overlay": "zig-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
@@ -91,6 +108,44 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"zig-overlay": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776789209,
|
||||
"narHash": "sha256-G6B7Q4TXn7MZ1mB+f9rymjsYF5PLWoSvmbxijb/99bw=",
|
||||
"owner": "mitchellh",
|
||||
"repo": "zig-overlay",
|
||||
"rev": "14fe971844e841297ddd2ce9783d6892b467af39",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "mitchellh",
|
||||
"repo": "zig-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
zig-overlay = {
|
||||
url = "github:mitchellh/zig-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
@@ -21,6 +26,7 @@
|
||||
crane,
|
||||
flake-utils,
|
||||
rust-overlay,
|
||||
zig-overlay,
|
||||
...
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
@@ -31,6 +37,10 @@
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
|
||||
# zlob requires Zig >= 0.16, but nixpkgs tops out at 0.15. Pull from
|
||||
# mitchellh/zig-overlay which ships every released version.
|
||||
zig = zig-overlay.packages.${system}."0.16.0";
|
||||
|
||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
@@ -45,12 +55,23 @@
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
strictDeps = true;
|
||||
|
||||
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl pkgs.zig pkgs.llvmPackages.libclang.lib ];
|
||||
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl zig pkgs.llvmPackages.libclang.lib ];
|
||||
buildInputs = with pkgs; [
|
||||
# Add additional build inputs here
|
||||
openssl
|
||||
];
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
|
||||
# Zig 0.16 insists on writing to its global cache even when the
|
||||
# zlob build.rs passes --global-cache-dir. In the nix sandbox $HOME
|
||||
# is /homeless-shelter (unwritable), so redirect to $TMPDIR before
|
||||
# the build phase runs.
|
||||
preBuild = ''
|
||||
export ZIG_GLOBAL_CACHE_DIR="$TMPDIR/zig-global-cache"
|
||||
export ZIG_LOCAL_CACHE_DIR="$TMPDIR/zig-local-cache"
|
||||
export XDG_CACHE_HOME="$TMPDIR/cache"
|
||||
mkdir -p "$ZIG_GLOBAL_CACHE_DIR" "$ZIG_LOCAL_CACHE_DIR" "$XDG_CACHE_HOME"
|
||||
'';
|
||||
};
|
||||
|
||||
my-crate = craneLib.buildPackage (
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
FFF MCP Server installer for Windows.
|
||||
.DESCRIPTION
|
||||
Pipe usage:
|
||||
irm https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.ps1 | iex
|
||||
Direct usage (supports params):
|
||||
iwr https://.../install-mcp.ps1 -OutFile install-mcp.ps1; .\install-mcp.ps1 -Version v0.1.2
|
||||
Env-var fallbacks (for the piped form):
|
||||
$env:FFF_MCP_VERSION, $env:FFF_MCP_INSTALL_DIR
|
||||
.PARAMETER Version
|
||||
Release tag to install (e.g. 'v0.1.2'). Default: latest release containing a Windows MCP asset.
|
||||
.PARAMETER InstallDir
|
||||
Target install directory. Default: $env:LOCALAPPDATA\fff-mcp\bin.
|
||||
.PARAMETER PathScope
|
||||
How to persist PATH: 'User' (set user env var, default), 'Profile' (append to $PROFILE *nix-style), 'None' (do not persist).
|
||||
Env-var fallback: $env:FFF_MCP_PATH_SCOPE.
|
||||
#>
|
||||
param(
|
||||
[string]$Version = $env:FFF_MCP_VERSION,
|
||||
[string]$InstallDir = $env:FFF_MCP_INSTALL_DIR,
|
||||
[ValidateSet('User', 'Profile', 'None')]
|
||||
[string]$PathScope
|
||||
)
|
||||
if (-not $PathScope) {
|
||||
$PathScope = if ($env:FFF_MCP_PATH_SCOPE) { $env:FFF_MCP_PATH_SCOPE } else { 'User' }
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Force TLS 1.2 — PS 5.1 on older Win10 may default to SSL3/TLS1.0 which GitHub rejects.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$Repo = 'dmtrKovalenko/fff.nvim'
|
||||
$BinaryName = 'fff-mcp'
|
||||
if (-not $InstallDir) { $InstallDir = Join-Path $env:LOCALAPPDATA 'fff-mcp\bin' }
|
||||
|
||||
function Write-Info { param($m) Write-Host $m -ForegroundColor Blue }
|
||||
function Write-Success { param($m) Write-Host $m -ForegroundColor DarkYellow }
|
||||
function Write-Warn { param($m) Write-Host $m -ForegroundColor Yellow }
|
||||
|
||||
function Get-Target {
|
||||
# Read from registry — env vars lie under x86/ARM64 emulation. Same approach Bun uses.
|
||||
$arch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment').PROCESSOR_ARCHITECTURE
|
||||
switch ($arch) {
|
||||
'AMD64' { return 'x86_64-pc-windows-msvc' }
|
||||
'ARM64' { return 'aarch64-pc-windows-msvc' }
|
||||
default { throw "Unsupported architecture: $arch" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LatestReleaseTag {
|
||||
param([string]$Target)
|
||||
$asset = "$BinaryName-$Target.exe"
|
||||
$headers = @{ 'User-Agent' = 'fff-mcp-installer' }
|
||||
if ($env:GITHUB_TOKEN) { $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" }
|
||||
|
||||
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases" -Headers $headers
|
||||
$rel = $releases | Where-Object { $_.assets.name -contains $asset } | Select-Object -First 1
|
||||
if (-not $rel) {
|
||||
throw "No release found containing $asset. The MCP build may not have been released for this platform yet."
|
||||
}
|
||||
return $rel.tag_name
|
||||
}
|
||||
|
||||
function Invoke-Download {
|
||||
param([string]$Url, [string]$OutFile)
|
||||
# curl.exe (ships with Win10 1803+) is faster than iwr on PS 5.1. Fall back to iwr.
|
||||
$curl = Get-Command curl.exe -ErrorAction SilentlyContinue
|
||||
if ($curl) {
|
||||
& $curl.Source -fsSL -o $OutFile $Url
|
||||
if ($LASTEXITCODE -ne 0) { throw "curl.exe exited with $LASTEXITCODE" }
|
||||
} else {
|
||||
$prev = $ProgressPreference
|
||||
try {
|
||||
# iwr progress bar tanks throughput on PS 5.1.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing
|
||||
} finally {
|
||||
$ProgressPreference = $prev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Install-Binary {
|
||||
param([string]$Target, [string]$Tag)
|
||||
|
||||
$filename = "$BinaryName-$Target.exe"
|
||||
$url = "https://github.com/$Repo/releases/download/$Tag/$filename"
|
||||
|
||||
Write-Info "Downloading $filename from release $Tag..."
|
||||
|
||||
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
|
||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||
try {
|
||||
$tmpFile = Join-Path $tmp $filename
|
||||
try {
|
||||
Invoke-Download -Url $url -OutFile $tmpFile
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "Error: Failed to download binary for your platform." -ForegroundColor Red
|
||||
Write-Host " URL: $url"
|
||||
Write-Host " Release: $Tag"
|
||||
Write-Host " Platform: $Target"
|
||||
Write-Host "Check available releases at: https://github.com/$Repo/releases"
|
||||
throw
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
$dest = Join-Path $InstallDir "$BinaryName.exe"
|
||||
Move-Item -Force -Path $tmpFile -Destination $dest
|
||||
return $dest
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Test-OnPath {
|
||||
param([string]$Dir)
|
||||
$paths = $env:PATH -split ';'
|
||||
return ($paths -contains $Dir) -or ($paths -contains $Dir.TrimEnd('\'))
|
||||
}
|
||||
|
||||
function Add-ToUserPath {
|
||||
param([string]$Dir)
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath) { $userPath = '' }
|
||||
$entries = $userPath -split ';' | Where-Object { $_ -ne '' }
|
||||
if ($entries -notcontains $Dir) {
|
||||
$newPath = (@($entries + $Dir) -join ';')
|
||||
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
||||
Write-Success "Added $Dir to user PATH."
|
||||
}
|
||||
}
|
||||
|
||||
function Add-ToProfilePath {
|
||||
param([string]$Dir)
|
||||
$profilePath = $PROFILE.CurrentUserAllHosts
|
||||
$line = "`$env:PATH += ';$Dir' # added by fff-mcp installer"
|
||||
if (Test-Path $profilePath) {
|
||||
$existing = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue
|
||||
if ($existing -and $existing.Contains($Dir)) { return }
|
||||
} else {
|
||||
New-Item -ItemType File -Force -Path $profilePath | Out-Null
|
||||
}
|
||||
Add-Content -Path $profilePath -Value "`n$line"
|
||||
Write-Success "Appended PATH update to $profilePath."
|
||||
}
|
||||
|
||||
function Set-Path {
|
||||
param([string]$Dir, [string]$Scope)
|
||||
switch ($Scope) {
|
||||
'User' { Add-ToUserPath $Dir }
|
||||
'Profile' { Add-ToProfilePath $Dir }
|
||||
'None' { Write-Info "Skipping PATH persistence (-PathScope None)." }
|
||||
}
|
||||
# Make available in current session regardless of scope.
|
||||
if (-not (Test-OnPath $Dir)) { $env:PATH = "$env:PATH;$Dir" }
|
||||
}
|
||||
|
||||
function Show-SetupInstructions {
|
||||
param([string]$BinaryPath)
|
||||
$foundAny = $false
|
||||
|
||||
Write-Host ""
|
||||
Write-Success "FFF MCP Server installed successfully!"
|
||||
Write-Host ""
|
||||
Write-Info "Setup with your AI coding assistant:"
|
||||
Write-Host ""
|
||||
|
||||
if (Get-Command claude -ErrorAction SilentlyContinue) {
|
||||
$foundAny = $true
|
||||
Write-Success "[Claude Code] detected"
|
||||
Write-Host ""
|
||||
Write-Host "Global (recommended):"
|
||||
Write-Host "claude mcp add -s user fff -- $BinaryPath"
|
||||
Write-Host ""
|
||||
Write-Host "Or project-level .mcp.json (uses PATH):"
|
||||
Write-Host @'
|
||||
{
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if (Get-Command opencode -ErrorAction SilentlyContinue) {
|
||||
$foundAny = $true
|
||||
Write-Success "[OpenCode] detected"
|
||||
Write-Host "Add to your opencode.json:"
|
||||
Write-Host @'
|
||||
{
|
||||
"mcp": {
|
||||
"fff": {
|
||||
"type": "local",
|
||||
"command": ["fff-mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if (Get-Command codex -ErrorAction SilentlyContinue) {
|
||||
$foundAny = $true
|
||||
Write-Success "[Codex] detected"
|
||||
Write-Host "codex mcp add fff -- fff-mcp"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if (-not $foundAny) {
|
||||
Write-Host "No AI coding assistants detected."
|
||||
Write-Host "Binary path: $BinaryPath"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "Binary: $BinaryPath"
|
||||
Write-Host "Docs: https://github.com/$Repo"
|
||||
Write-Host ""
|
||||
Write-Info "Tip: Add this to your CLAUDE.md or AGENTS.md to make AI use fff for all searches:"
|
||||
Write-Host '"Use the fff MCP tools for all file search operations instead of default tools."'
|
||||
}
|
||||
|
||||
function Main {
|
||||
$target = Get-Target
|
||||
|
||||
$existing = Join-Path $InstallDir "$BinaryName.exe"
|
||||
$isUpdate = Test-Path $existing
|
||||
|
||||
if ($isUpdate) {
|
||||
Write-Info "Updating FFF MCP Server..."
|
||||
} else {
|
||||
Write-Info "Installing FFF MCP Server..."
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Info "Detected platform: $target"
|
||||
|
||||
if ($Version) {
|
||||
$tag = $Version
|
||||
Write-Info "Using pinned version: $tag"
|
||||
} else {
|
||||
$tag = Get-LatestReleaseTag -Target $target
|
||||
}
|
||||
$binaryPath = Install-Binary -Target $target -Tag $tag
|
||||
|
||||
if ($isUpdate) {
|
||||
Write-Host ""
|
||||
Write-Success "FFF MCP Server updated to $tag!"
|
||||
Write-Host ""
|
||||
} else {
|
||||
Set-Path -Dir $InstallDir -Scope $PathScope
|
||||
Show-SetupInstructions -BinaryPath $binaryPath
|
||||
}
|
||||
}
|
||||
|
||||
Main
|
||||
@@ -33,6 +33,7 @@ local M = {}
|
||||
--- @field toggle_debug string
|
||||
--- @field cycle_grep_modes string
|
||||
--- @field cycle_previous_query string
|
||||
--- @field cycle_forward_query string
|
||||
--- @field toggle_select string
|
||||
--- @field send_to_quickfix string
|
||||
--- @field focus_list string
|
||||
@@ -54,6 +55,7 @@ local M = {}
|
||||
--- @field smart_case boolean
|
||||
--- @field time_budget_ms number
|
||||
--- @field modes string[]
|
||||
--- @field trim_whitespace boolean
|
||||
|
||||
--- @class FffConfig
|
||||
--- @field base_path string
|
||||
@@ -62,6 +64,7 @@ local M = {}
|
||||
--- @field max_results number
|
||||
--- @field max_threads number
|
||||
--- @field lazy_sync boolean
|
||||
--- @field prompt_vim_mode boolean
|
||||
--- @field layout FffLayoutConfig
|
||||
--- @field preview FffPreviewConfig
|
||||
--- @field keymaps FffKeymapsConfig
|
||||
@@ -195,6 +198,7 @@ local function init()
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
prompt_vim_mode = false, -- set to true to enable vim-mode in the prompt: <Esc> leaves insert for normal mode bindings (also allows <leader>p or <leader>l to jump around) the second <Esc> closes the picker
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
@@ -244,6 +248,8 @@ local function init()
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- goes to the next query in history (forward)
|
||||
cycle_forward_query = '<C-Down>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
@@ -332,6 +338,7 @@ local function init()
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+41
-13
@@ -46,22 +46,50 @@ local function setup_global_autocmds(config)
|
||||
vim.api.nvim_create_autocmd('DirChanged', {
|
||||
group = group,
|
||||
callback = function()
|
||||
-- Window-local `:lcd` / `:tcd` are per-window — they don't change the
|
||||
-- effective project root for the picker, so bail before touching
|
||||
-- anything else.
|
||||
if vim.v.event.scope == 'window' then return end
|
||||
if not state.initialized then return end
|
||||
|
||||
local new_cwd = vim.v.event.cwd
|
||||
if state.initialized and new_cwd and new_cwd ~= config.base_path then
|
||||
vim.schedule(function()
|
||||
-- Delay require to avoid circular dependency: core -> main -> picker_ui -> file_picker -> core
|
||||
local ok, picker = pcall(require, 'fff.main')
|
||||
if not ok then
|
||||
vim.notify('FFF: Failed to load main module: ' .. tostring(picker), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local change_ok, err = pcall(picker.change_indexing_directory, new_cwd)
|
||||
if not change_ok then
|
||||
vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end)
|
||||
if not new_cwd or new_cwd == '' then return end
|
||||
|
||||
-- Canonicalize both sides before comparing. `vim.v.event.cwd` is
|
||||
-- whatever the caller passed to `:cd` (often unexpanded, sometimes
|
||||
-- containing `~` or symlinks), while `config.base_path` is the form
|
||||
-- the picker was last re-indexed against (post-`expand`). Without
|
||||
-- resolving symlinks + ensuring an absolute path, trivially
|
||||
-- equivalent paths compare as different (`/private/var/x` vs
|
||||
-- `/var/x` on macOS, resolved-vs-unresolved symlinks from LSP root
|
||||
-- detection, etc.) and every such mismatch schedules a 450k-file
|
||||
-- reindex through the Rust side.
|
||||
local function canonicalize(p)
|
||||
if not p or p == '' then return p end
|
||||
local abs = vim.fn.fnamemodify(vim.fn.expand(p), ':p')
|
||||
-- `:p` leaves a trailing slash on directories — strip for
|
||||
-- comparison stability.
|
||||
abs = abs:gsub('/+$', '')
|
||||
local ok, resolved = pcall(vim.fn.resolve, abs)
|
||||
return (ok and resolved ~= '') and resolved or abs
|
||||
end
|
||||
|
||||
local new_canonical = canonicalize(new_cwd)
|
||||
local base_canonical = canonicalize(config.base_path)
|
||||
if new_canonical == base_canonical then return end
|
||||
|
||||
vim.schedule(function()
|
||||
-- Delay require to avoid circular dependency: core -> main -> picker_ui -> file_picker -> core
|
||||
local ok, picker = pcall(require, 'fff.main')
|
||||
if not ok then
|
||||
vim.notify('FFF: Failed to load main module: ' .. tostring(picker), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local change_ok, err = pcall(picker.change_indexing_directory, new_canonical)
|
||||
if not change_ok then
|
||||
vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
desc = 'Automatically sync FFF directory changes',
|
||||
})
|
||||
|
||||
@@ -799,7 +799,7 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
return false
|
||||
end
|
||||
|
||||
local info = M.get_file_info(file.path)
|
||||
local info = M.get_file_info(file.relative_path)
|
||||
if not info then
|
||||
set_buffer_lines(bufnr, { 'File info unavailable' })
|
||||
return false
|
||||
|
||||
@@ -182,11 +182,27 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
local base_id = vim.fn.synIDtrans(vim.fn.hlID(base_hl))
|
||||
local cursor_id = vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor))
|
||||
local border_fg_gui = vim.fn.synIDattr(base_id, 'fg', 'gui')
|
||||
local border_fg_cterm = vim.fn.synIDattr(base_id, 'fg', 'cterm')
|
||||
local cursor_bg_gui = vim.fn.synIDattr(cursor_id, 'bg', 'gui')
|
||||
local cursor_bg_cterm = vim.fn.synIDattr(cursor_id, 'bg', 'cterm')
|
||||
local has_gui = border_fg_gui ~= '' and cursor_bg_gui ~= ''
|
||||
local has_cterm = border_fg_cterm ~= '' and cursor_bg_cterm ~= ''
|
||||
|
||||
if has_gui or has_cterm then
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
local hl_opts = {}
|
||||
if has_gui then
|
||||
hl_opts.fg = border_fg_gui
|
||||
hl_opts.bg = cursor_bg_gui
|
||||
end
|
||||
if has_cterm then
|
||||
hl_opts.ctermfg = tonumber(border_fg_cterm)
|
||||
hl_opts.ctermbg = tonumber(cursor_bg_cterm)
|
||||
end
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, hl_opts)
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
@@ -214,7 +230,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
end
|
||||
|
||||
-- 8. Selection
|
||||
if ctx.selected_files and ctx.selected_files[item.path] then
|
||||
if ctx.selected_files and ctx.selected_files[item.relative_path] then
|
||||
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
@@ -225,7 +241,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,
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ if not ok then error('Failed to load fff.rust module: ' .. rust_module) end
|
||||
|
||||
-- export all functions from the Rust module
|
||||
M.init_db = rust_module.init_db
|
||||
M.destroy_db = rust_module.destroy_db
|
||||
M.destroy_frecency_db = rust_module.destroy_frecency_db
|
||||
M.access = rust_module.access
|
||||
M.set_provider_items = rust_module.set_provider_items
|
||||
M.fuzzy = rust_module.fuzzy
|
||||
@@ -46,6 +46,7 @@ M.get_git_root = rust_module.get_git_root
|
||||
|
||||
-- Grep functions
|
||||
M.live_grep = rust_module.live_grep
|
||||
M.parse_grep_query = rust_module.parse_grep_query
|
||||
|
||||
-- Utility functions
|
||||
M.health_check = rust_module.health_check
|
||||
|
||||
@@ -184,7 +184,7 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
|
||||
|
||||
-- 6. Selection marker (per-occurrence in grep mode)
|
||||
if ctx.selected_items then
|
||||
local key = string.format('%s:%d:%d', item.path, item.line_number or 0, item.col or 0)
|
||||
local key = string.format('%s:%d:%d', item.relative_path, item.line_number or 0, item.col or 0)
|
||||
if ctx.selected_items[key] then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
|
||||
sign_text = '▊',
|
||||
@@ -204,8 +204,8 @@ end
|
||||
function M.render_line(item, ctx)
|
||||
-- Track file grouping across the render pass via ctx
|
||||
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
|
||||
local is_new_group = (item.path ~= ctx._grep_last_file)
|
||||
ctx._grep_last_file = item.path
|
||||
local is_new_group = (item.relative_path ~= ctx._grep_last_file)
|
||||
ctx._grep_last_file = item.relative_path
|
||||
|
||||
local match_line = render_match_line(item, ctx)
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ function M.search(query, file_offset, page_size, config, grep_mode)
|
||||
conf.max_matches_per_file,
|
||||
conf.smart_case,
|
||||
grep_mode or 'plain',
|
||||
conf.time_budget_ms
|
||||
conf.time_budget_ms,
|
||||
conf.trim_whitespace
|
||||
)
|
||||
return last_result
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
--- produce N + K buffer lines (where K = number of virtual header rows), but
|
||||
--- the page_size contract with Rust stays item-based.
|
||||
---
|
||||
--- Selection always operates on item.path keys. Virtual rows have no identity
|
||||
--- Selection always operates on item.relative_path keys. Virtual rows have no identity
|
||||
--- of their own — they derive from the item they belong to.
|
||||
local M = {}
|
||||
|
||||
|
||||
@@ -178,16 +178,13 @@ function M.highlight_grep_matches(bufnr, location, namespace)
|
||||
|
||||
local query = location.grep_query
|
||||
|
||||
-- Extract the actual search text from the grep query (strip file constraints like *.rs /src/)
|
||||
-- The query parser uses space-separated tokens; the first non-constraint token is the pattern.
|
||||
-- Simple heuristic: strip tokens that look like constraints (start with *, /, or !)
|
||||
local search_text = query
|
||||
local parts = vim.split(query, '%s+')
|
||||
local text_parts = {}
|
||||
for _, part in ipairs(parts) do
|
||||
if part ~= '' and not part:match('^[%*!/]') and not part:match('^%.') then table.insert(text_parts, part) end
|
||||
end
|
||||
if #text_parts > 0 then search_text = text_parts[1] end
|
||||
-- Use the Rust GrepConfig parser as the single source of truth for
|
||||
-- stripping constraint tokens. This avoids duplicating constraint
|
||||
-- detection in Lua, which would break whenever a new token type is added.
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
local parsed = fuzzy.parse_grep_query(query)
|
||||
local search_text = parsed.grep_text
|
||||
if search_text == '' then search_text = query end
|
||||
|
||||
if not search_text or search_text == '' then return nil end
|
||||
|
||||
|
||||
+35
-4
@@ -55,6 +55,36 @@ function M.find_in_git_root()
|
||||
M.find_files_in_dir(git_root)
|
||||
end
|
||||
|
||||
--- Clear FFF caches (both in-memory state and on-disk database files)
|
||||
--- @param scope? string Cache scope: all|frecency|files
|
||||
function M.clear_cache(scope)
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
if not scope or scope == '' then scope = 'all' end
|
||||
|
||||
local errors = {}
|
||||
|
||||
if scope == 'all' or scope == 'files' then
|
||||
local ok, err = pcall(fuzzy.cleanup_file_picker)
|
||||
if not ok then table.insert(errors, 'cleanup file picker: ' .. tostring(err)) end
|
||||
end
|
||||
|
||||
if scope == 'all' or scope == 'frecency' then
|
||||
local ok, err = pcall(fuzzy.destroy_frecency_db)
|
||||
if not ok then table.insert(errors, 'destroy frecency db: ' .. tostring(err)) end
|
||||
|
||||
ok, err = pcall(fuzzy.destroy_query_db)
|
||||
if not ok then table.insert(errors, 'destroy query db: ' .. tostring(err)) end
|
||||
end
|
||||
|
||||
if #errors > 0 then
|
||||
vim.notify('FFF: errors clearing cache: ' .. table.concat(errors, '; '), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
vim.notify('Cleared FFF cache: ' .. scope, vim.log.levels.INFO)
|
||||
return true
|
||||
end
|
||||
|
||||
--- Trigger rescan of files in the current directory
|
||||
function M.scan_files()
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
@@ -129,8 +159,9 @@ function M.search_and_show(query)
|
||||
|
||||
for i, file in ipairs(files) do
|
||||
if i <= 15 then
|
||||
local icon = file.extension ~= '' and '.' .. file.extension or '📄'
|
||||
local frecency = file.frecency_score > 0 and ' ⭐' .. file.frecency_score or ''
|
||||
local file_extension = vim.fn.fnamemodify(file.name, ':e')
|
||||
local icon = file_extension ~= '' and '.' .. file_extension or '📄'
|
||||
local frecency = file.total_frecency_score > 0 and ' ⭐' .. file.total_frecency_score or ''
|
||||
print(' ' .. i .. '. ' .. icon .. ' ' .. file.relative_path .. frecency)
|
||||
end
|
||||
end
|
||||
@@ -198,8 +229,8 @@ function M.open_file_under_cursor(open_cb)
|
||||
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
|
||||
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
|
||||
if open_cb and type(open_cb) == 'function' then open_cb(files[1].path) end
|
||||
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].path)))
|
||||
if open_cb and type(open_cb) == 'function' then open_cb(files[1].relative_path) end
|
||||
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].relative_path)))
|
||||
|
||||
if location then vim.schedule(function() require('fff.location_utils').jump_to_location(location) end) end
|
||||
|
||||
|
||||
+231
-51
@@ -10,6 +10,26 @@ local list_renderer = require('fff.list_renderer')
|
||||
local scrollbar = require('fff.scrollbar')
|
||||
local rust = require('fff.rust')
|
||||
|
||||
--- Base path of picker can change that's why we can not rely on relative
|
||||
--- path for reading/opening files. This function resolves correct absolute path
|
||||
--- @param relative_path string|nil
|
||||
--- @return string|nil
|
||||
local function canonicalize_fff_path(relative_path)
|
||||
if not relative_path or relative_path == '' then return nil end
|
||||
local path = relative_path
|
||||
-- Strip Windows long-path prefix (\\?\) — Neovim cannot open these.
|
||||
if vim.startswith(path, '\\\\?\\') then path = path:sub(5) end
|
||||
-- Already absolute: don't re-anchor.
|
||||
if vim.fn.fnamemodify(path, ':p') == path then return path end
|
||||
local base = conf.get().base_path
|
||||
if not base or base == '' then return path end
|
||||
return vim.fs.normalize(base .. '/' .. path)
|
||||
end
|
||||
|
||||
--- @param item table|nil
|
||||
--- @return string|nil
|
||||
local function resolve_item_path(item) return item and canonicalize_fff_path(item.relative_path) or nil end
|
||||
|
||||
local BORDER_PRESETS = {
|
||||
single = { '┌', '─', '┐', '│', '┘', '─', '└', '│' },
|
||||
double = { '╔', '═', '╗', '║', '╝', '═', '╚', '║' },
|
||||
@@ -115,34 +135,110 @@ local function compute_layout(config)
|
||||
local width = math.floor(terminal_width * width_ratio)
|
||||
local height = math.floor(terminal_height * height_ratio)
|
||||
|
||||
local col_ratio_default = 0.5 - (width_ratio / 2)
|
||||
local col_ratio = col_ratio_default
|
||||
-- Account for chrome (statusline, tabline, cmdheight) for edge-anchored positions
|
||||
local has_tabline = vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1)
|
||||
local has_statusline = vim.o.laststatus > 0
|
||||
local top_edge = has_tabline and 1 or 0
|
||||
local bottom_edge = terminal_height - vim.o.cmdheight - (has_statusline and 1 or 0)
|
||||
local usable_height = bottom_edge - top_edge
|
||||
height = math.min(height, usable_height)
|
||||
|
||||
-- Anchor controls default placement; manual col/row overrides still work
|
||||
local anchor = utils.resolve_config_value(
|
||||
config.layout.anchor,
|
||||
terminal_width,
|
||||
terminal_height,
|
||||
function(v)
|
||||
return utils.is_one_of(v, {
|
||||
'center',
|
||||
'top_left',
|
||||
'top',
|
||||
'top_right',
|
||||
'left',
|
||||
'right',
|
||||
'bottom_left',
|
||||
'bottom',
|
||||
'bottom_right',
|
||||
})
|
||||
end,
|
||||
'center',
|
||||
'layout.anchor'
|
||||
)
|
||||
|
||||
-- Compute default positions as direct pixel values.
|
||||
-- Edge-flush anchors compensate for offsets added by calculate_layout_dimensions:
|
||||
-- col: -1 for left (internal +1 on list_col makes it flush)
|
||||
-- -2 for right (internal +1 plus the preview window's independent right border)
|
||||
-- row: -1 for top/bottom (internal +1 on rows; bottom also accounts for chrome via bottom_edge)
|
||||
local center_col = math.floor((terminal_width - width) / 2)
|
||||
local center_row = top_edge + math.floor((usable_height - height) / 2)
|
||||
local anchor_positions = {
|
||||
center = {
|
||||
col = center_col,
|
||||
row = center_row,
|
||||
},
|
||||
top_left = {
|
||||
col = -1,
|
||||
row = top_edge - 1,
|
||||
},
|
||||
top = {
|
||||
col = center_col,
|
||||
row = top_edge - 1,
|
||||
},
|
||||
top_right = {
|
||||
col = terminal_width - width - 2,
|
||||
row = top_edge - 1,
|
||||
},
|
||||
left = {
|
||||
col = -1,
|
||||
row = center_row,
|
||||
},
|
||||
right = {
|
||||
col = terminal_width - width - 2,
|
||||
row = center_row,
|
||||
},
|
||||
bottom_left = {
|
||||
col = -1,
|
||||
row = bottom_edge - height - 1,
|
||||
},
|
||||
bottom = {
|
||||
col = center_col,
|
||||
row = bottom_edge - height - 1,
|
||||
},
|
||||
bottom_right = {
|
||||
col = terminal_width - width - 2,
|
||||
row = bottom_edge - height - 1,
|
||||
},
|
||||
}
|
||||
|
||||
local pos = anchor_positions[anchor] or anchor_positions.center
|
||||
local col = pos.col
|
||||
local row = pos.row
|
||||
|
||||
-- Allow manual ratio overrides (backwards compat)
|
||||
if config.layout.col ~= nil then
|
||||
col_ratio = utils.resolve_config_value(
|
||||
local col_ratio = utils.resolve_config_value(
|
||||
config.layout.col,
|
||||
terminal_width,
|
||||
terminal_height,
|
||||
utils.is_valid_ratio,
|
||||
col_ratio_default,
|
||||
col / terminal_width,
|
||||
'layout.col'
|
||||
)
|
||||
col = math.floor(terminal_width * col_ratio)
|
||||
end
|
||||
local row_ratio_default = 0.5 - (height_ratio / 2)
|
||||
local row_ratio = row_ratio_default
|
||||
if config.layout.row ~= nil then
|
||||
row_ratio = utils.resolve_config_value(
|
||||
local row_ratio = utils.resolve_config_value(
|
||||
config.layout.row,
|
||||
terminal_width,
|
||||
terminal_height,
|
||||
utils.is_valid_ratio,
|
||||
row_ratio_default,
|
||||
row / terminal_height,
|
||||
'layout.row'
|
||||
)
|
||||
row = math.floor(terminal_height * row_ratio)
|
||||
end
|
||||
|
||||
local col = math.floor(terminal_width * col_ratio)
|
||||
local row = math.floor(terminal_height * row_ratio)
|
||||
|
||||
local prompt_position = get_prompt_position()
|
||||
local preview_position = get_preview_position()
|
||||
|
||||
@@ -837,13 +933,19 @@ function M.setup_keymaps()
|
||||
set_keymap('i', keymaps.move_up, M.move_up, input_opts)
|
||||
set_keymap('i', keymaps.move_down, M.move_down, input_opts)
|
||||
set_keymap('i', keymaps.cycle_previous_query, M.recall_query_from_history, input_opts)
|
||||
set_keymap('i', keymaps.cycle_forward_query, M.cycle_forward_query, input_opts)
|
||||
set_keymap('n', 'j', M.move_down, input_opts)
|
||||
set_keymap('n', 'k', M.move_up, input_opts)
|
||||
set_keymap('n', keymaps.focus_list, M.focus_list_win, input_opts)
|
||||
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, input_opts)
|
||||
|
||||
-- Input buffer: both modes
|
||||
set_keymap({ 'i', 'n' }, keymaps.close, M.close, input_opts)
|
||||
if M.state.config.prompt_vim_mode then
|
||||
set_keymap('n', keymaps.close, M.close, input_opts)
|
||||
set_keymap('i', '<C-c>', M.close, input_opts)
|
||||
else
|
||||
set_keymap({ 'i', 'n' }, keymaps.close, M.close, input_opts)
|
||||
end
|
||||
|
||||
set_keymap({ 'i', 'n' }, keymaps.select, M.select, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.select_split, function() M.select('split') end, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.select_vsplit, function() M.select('vsplit') end, input_opts)
|
||||
@@ -894,6 +996,16 @@ function M.setup_keymaps()
|
||||
vim.schedule(function() M.on_input_change() end)
|
||||
end,
|
||||
})
|
||||
|
||||
if M.state.config.prompt_vim_mode then
|
||||
vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
|
||||
buffer = M.state.input_buf,
|
||||
callback = function()
|
||||
local prompt_len = #M.state.config.prompt
|
||||
if vim.fn.col('.') <= prompt_len then vim.fn.cursor(vim.fn.line('.'), prompt_len + 1) end
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function M.focus_input_win()
|
||||
@@ -1326,7 +1438,7 @@ function M.update_preview_smart()
|
||||
end
|
||||
|
||||
-- Same file: update immediately (just scrolling/re-highlighting, no file I/O)
|
||||
if M.state.last_preview_file == item.path then
|
||||
if M.state.last_preview_file == item.relative_path then
|
||||
M.update_preview()
|
||||
return
|
||||
end
|
||||
@@ -1629,7 +1741,7 @@ end
|
||||
function M.update_preview_title(item, location)
|
||||
if not M.state.preview_win or not vim.api.nvim_win_is_valid(M.state.preview_win) then return end
|
||||
|
||||
local relative_path = item.relative_path or item.path
|
||||
local relative_path = item.relative_path
|
||||
local max_title_width = vim.api.nvim_win_get_width(M.state.preview_win)
|
||||
|
||||
-- Append :line for grep mode or grep suggestions
|
||||
@@ -1752,10 +1864,10 @@ function M.update_preview()
|
||||
|
||||
local location_changed = not vim.deep_equal(M.state.last_preview_location, effective_location)
|
||||
|
||||
if M.state.last_preview_file == item.path and not location_changed then return end
|
||||
if M.state.last_preview_file == item.relative_path and not location_changed then return end
|
||||
|
||||
-- Same file, different location: just scroll and re-highlight instead of reloading
|
||||
if M.state.last_preview_file == item.path and location_changed then
|
||||
if M.state.last_preview_file == item.relative_path and location_changed then
|
||||
M.state.last_preview_location = effective_location and vim.deepcopy(effective_location) or nil
|
||||
preview.state.location = effective_location
|
||||
-- Update title with new line number for grep/suggestion mode
|
||||
@@ -1770,7 +1882,7 @@ function M.update_preview()
|
||||
|
||||
preview.clear()
|
||||
|
||||
M.state.last_preview_file = item.path
|
||||
M.state.last_preview_file = item.relative_path
|
||||
M.state.last_preview_location = effective_location and vim.deepcopy(effective_location) or nil
|
||||
|
||||
M.update_preview_title(item, effective_location)
|
||||
@@ -1778,7 +1890,7 @@ function M.update_preview()
|
||||
if M.state.file_info_buf then preview.update_file_info_buffer(item, M.state.file_info_buf, M.state.cursor) end
|
||||
|
||||
preview.set_preview_window(M.state.preview_win)
|
||||
preview.preview(item.path, M.state.preview_buf, effective_location, item.is_binary)
|
||||
preview.preview(resolve_item_path(item), M.state.preview_buf, effective_location, item.is_binary)
|
||||
end
|
||||
|
||||
--- Clear preview
|
||||
@@ -2117,6 +2229,56 @@ function M.recall_query_from_history()
|
||||
end)
|
||||
end
|
||||
|
||||
--- Cycle forward through query history (toward more recent queries).
|
||||
--- Complements recall_query_from_history which cycles backward.
|
||||
--- Allows bidirectional navigation without losing your place.
|
||||
function M.cycle_forward_query()
|
||||
if not M.state.active then return end
|
||||
|
||||
-- Initialize offset on first press (start from most recent, same as backward)
|
||||
if M.state.history_offset == nil then
|
||||
M.state.history_offset = 0
|
||||
elseif M.state.history_offset > 0 then
|
||||
-- Decrement offset to move forward toward more recent queries
|
||||
M.state.history_offset = M.state.history_offset - 1
|
||||
else
|
||||
-- At the most recent entry (offset 0), can't go further forward
|
||||
return
|
||||
end
|
||||
|
||||
-- Fetch query at current offset from Rust (grep and file picker have separate histories)
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local history_fn = M.state.mode == 'grep' and fuzzy.get_historical_grep_query or fuzzy.get_historical_query
|
||||
local ok, query = pcall(history_fn, M.state.history_offset)
|
||||
|
||||
if not ok or not query then
|
||||
-- Shouldn't happen since we validated the offset, but handle gracefully
|
||||
M.state.history_offset = nil
|
||||
return
|
||||
end
|
||||
|
||||
if M.state.mode ~= 'grep' then M.state.next_search_force_combo_boost = true end
|
||||
|
||||
-- this is going to trigger the on_input_change handler with the normal search and render flow
|
||||
vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. query })
|
||||
|
||||
-- Position cursor at end
|
||||
vim.schedule(function()
|
||||
if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then
|
||||
vim.api.nvim_win_set_cursor(M.state.input_win, { 1, #M.state.config.prompt + #query })
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Check whether the given window has 'winfixbuf' enabled.
|
||||
--- pcall-guarded so this stays safe on Neovim versions that predate the option.
|
||||
--- @param win number Window ID
|
||||
--- @return boolean
|
||||
local function window_has_winfixbuf(win)
|
||||
local ok, val = pcall(vim.api.nvim_get_option_value, 'winfixbuf', { win = win })
|
||||
return ok and val == true
|
||||
end
|
||||
|
||||
--- Find the first visible window with a normal file buffer
|
||||
--- @return number|nil Window ID of the first suitable window, or nil if none found
|
||||
local function find_suitable_window()
|
||||
@@ -2143,6 +2305,7 @@ local function find_suitable_window()
|
||||
and modifiable
|
||||
and not is_picker_window
|
||||
and filetype ~= 'undotree'
|
||||
and not window_has_winfixbuf(win)
|
||||
then
|
||||
return win
|
||||
end
|
||||
@@ -2157,7 +2320,9 @@ end
|
||||
--- Format: "path:line:col" — uniquely identifies one match entry.
|
||||
---@param item table Grep match item with path, line_number, col
|
||||
---@return string
|
||||
local function grep_item_key(item) return string.format('%s:%d:%d', item.path, item.line_number or 0, item.col or 0) end
|
||||
local function grep_item_key(item)
|
||||
return string.format('%s:%d:%d', item.relative_path, item.line_number or 0, item.col or 0)
|
||||
end
|
||||
|
||||
--- Toggle selection for the current item.
|
||||
--- In grep mode, selection is per-occurrence (individual match line).
|
||||
@@ -2170,7 +2335,7 @@ function M.toggle_select()
|
||||
|
||||
---@diagnostic disable-next-line: need-check-nil
|
||||
local item = items[M.state.cursor]
|
||||
if not item or not item.path then return end
|
||||
if not item or not item.relative_path then return end
|
||||
|
||||
local was_selected
|
||||
|
||||
@@ -2185,11 +2350,11 @@ function M.toggle_select()
|
||||
end
|
||||
else
|
||||
-- Per-file selection for normal file mode
|
||||
was_selected = M.state.selected_files[item.path]
|
||||
was_selected = M.state.selected_files[item.relative_path]
|
||||
if was_selected then
|
||||
M.state.selected_files[item.path] = nil
|
||||
M.state.selected_files[item.relative_path] = nil
|
||||
else
|
||||
M.state.selected_files[item.path] = true
|
||||
M.state.selected_files[item.relative_path] = true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2221,12 +2386,15 @@ function M.send_to_quickfix()
|
||||
if has_selections then
|
||||
-- Use explicitly selected items (survives page changes)
|
||||
for _, item in pairs(M.state.selected_items) do
|
||||
table.insert(qf_list, {
|
||||
filename = item.path,
|
||||
lnum = item.line_number or 1,
|
||||
col = (item.col or 0) + 1,
|
||||
text = item.line_content or vim.fn.fnamemodify(item.path, ':.'),
|
||||
})
|
||||
local abs = resolve_item_path(item)
|
||||
if abs then
|
||||
table.insert(qf_list, {
|
||||
filename = abs,
|
||||
lnum = item.line_number or 1,
|
||||
col = (item.col or 0) + 1,
|
||||
text = item.line_content or vim.fn.fnamemodify(abs, ':.'),
|
||||
})
|
||||
end
|
||||
end
|
||||
else
|
||||
-- No selections: run an exhaustive search to get all matches
|
||||
@@ -2241,12 +2409,13 @@ function M.send_to_quickfix()
|
||||
end
|
||||
|
||||
for _, item in ipairs(all_items) do
|
||||
if item and item.path then
|
||||
local abs = resolve_item_path(item)
|
||||
if abs then
|
||||
table.insert(qf_list, {
|
||||
filename = item.path,
|
||||
filename = abs,
|
||||
lnum = item.line_number or 1,
|
||||
col = (item.col or 0) + 1,
|
||||
text = item.line_content or vim.fn.fnamemodify(item.path, ':.'),
|
||||
text = item.line_content or vim.fn.fnamemodify(abs, ':.'),
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -2255,14 +2424,16 @@ function M.send_to_quickfix()
|
||||
-- Normal file mode: per-file entries at line 1
|
||||
local paths = {}
|
||||
|
||||
-- Collect from explicit selections, or fall back to all visible items
|
||||
-- Collect from explicit selections, or fall back to all visible items.
|
||||
-- selected_files is keyed by relative_path; filtered_items carries relative_path too.
|
||||
if next(M.state.selected_files) then
|
||||
for path, _ in pairs(M.state.selected_files) do
|
||||
table.insert(paths, path)
|
||||
for relative_path, _ in pairs(M.state.selected_files) do
|
||||
table.insert(paths, canonicalize_fff_path(relative_path))
|
||||
end
|
||||
else
|
||||
for _, item in ipairs(M.state.filtered_items) do
|
||||
if item and item.path then table.insert(paths, item.path) end
|
||||
local abs = resolve_item_path(item)
|
||||
if abs then table.insert(paths, abs) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2285,7 +2456,7 @@ function M.send_to_quickfix()
|
||||
local is_grep = M.state.mode == 'grep'
|
||||
M.close()
|
||||
|
||||
vim.fn.setqflist(qf_list, 'r')
|
||||
vim.fn.setqflist(qf_list)
|
||||
vim.cmd('copen')
|
||||
|
||||
local count = #qf_list
|
||||
@@ -2305,14 +2476,12 @@ function M.select(action)
|
||||
|
||||
action = action or 'edit'
|
||||
|
||||
-- Strip Windows long path prefix (\\?\) if present.
|
||||
-- These can surface from Rust's fs::canonicalize on Windows when LongPathsEnabled is set.
|
||||
-- Neovim cannot open paths with this prefix. The Rust side uses dunce::canonicalize to avoid
|
||||
-- producing these, but we strip defensively here as well.
|
||||
local path = item.path
|
||||
if vim.startswith(path, '\\\\?\\') then path = path:sub(5) end
|
||||
|
||||
local relative_path = vim.fn.fnamemodify(path, ':.')
|
||||
-- Anchor against the indexer's base_path (may differ from cwd), then rephrase
|
||||
-- as cwd-relative for a nicer buffer name when possible. When outside cwd,
|
||||
-- fnamemodify(':.') leaves the absolute path intact.
|
||||
local abs_path = resolve_item_path(item)
|
||||
if not abs_path then return end
|
||||
local relative_path = vim.fn.fnamemodify(abs_path, ':.')
|
||||
local location = M.state.location -- Capture location before closing
|
||||
local query = M.state.query -- Capture query before closing for tracking
|
||||
local mode = M.state.mode -- Capture mode before closing for tracking
|
||||
@@ -2348,17 +2517,28 @@ function M.select(action)
|
||||
M.close()
|
||||
|
||||
if action == 'edit' then
|
||||
local current_win = vim.api.nvim_get_current_win()
|
||||
local current_buf = vim.api.nvim_get_current_buf()
|
||||
local current_buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
|
||||
local current_buf_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
|
||||
local current_winfixbuf = window_has_winfixbuf(current_win)
|
||||
|
||||
-- If current active buffer is not a normal buffer we find a suitable window with a tab otherwise opening a new split
|
||||
if current_buftype ~= '' or not current_buf_modifiable then
|
||||
-- If the current window can't host a new buffer (special buftype, non-modifiable,
|
||||
-- or 'winfixbuf' locking it), retarget a suitable window or fall back to a split.
|
||||
-- Without this, :edit raises E1513 ("Cannot switch buffer. 'winfixbuf' is enabled")
|
||||
-- whenever the picker is invoked from a window pinned via :h winfixbuf.
|
||||
local opened_via_split = false
|
||||
if current_buftype ~= '' or not current_buf_modifiable or current_winfixbuf then
|
||||
local suitable_win = find_suitable_window()
|
||||
if suitable_win then vim.api.nvim_set_current_win(suitable_win) end
|
||||
if suitable_win then
|
||||
vim.api.nvim_set_current_win(suitable_win)
|
||||
elseif current_winfixbuf then
|
||||
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
|
||||
opened_via_split = true
|
||||
end
|
||||
end
|
||||
|
||||
vim.cmd('edit ' .. vim.fn.fnameescape(relative_path))
|
||||
if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(relative_path)) end
|
||||
elseif action == 'split' then
|
||||
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
|
||||
elseif action == 'vsplit' then
|
||||
@@ -2379,7 +2559,7 @@ function M.select(action)
|
||||
if mode == 'grep' then
|
||||
pcall(fff.track_grep_query, query)
|
||||
else
|
||||
pcall(fff.track_query_completion, query, item.path)
|
||||
pcall(fff.track_query_completion, query, item.relative_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Generated
+3840
-4
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": ["packages/fff-bun", "packages/fff-node"],
|
||||
|
||||
"workspaces": ["packages/fff-bun", "packages/fff-node", "packages/pi-fff"],
|
||||
"pi": {
|
||||
"extensions": ["./packages/pi-fff/src/index.ts"]
|
||||
},
|
||||
"scripts": {
|
||||
"format": "biome format --write",
|
||||
"format:check": "biome format",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user