Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ead91ec9d | |||
| 7de55be63d | |||
| 2606dce6bc | |||
| 8912a1abdb | |||
| d25ea7562b | |||
| ea1f9802d7 | |||
| 07e90e5898 | |||
| 0fd47ab72d | |||
| 7979004d77 | |||
| 210bc9e1c7 | |||
| 205f9d6db9 | |||
| 406bf2531c | |||
| 42a12051d8 | |||
| 477a8a2c96 | |||
| 9a6ee3d18a | |||
| 29585a4aa3 | |||
| 1c2c0633cd | |||
| 5158ba64b8 | |||
| 51e0ef7a64 | |||
| cd0efe50d3 | |||
| 541c3f5722 | |||
| 59d626dacd | |||
| 29e6480ea0 | |||
| db4cd2825c | |||
| b1c4f8e7d7 | |||
| feaae7de28 | |||
| c2d76b5466 | |||
| d4b9d16073 | |||
| 094a35e435 | |||
| 697481fd29 | |||
| bb6f32a2ad | |||
| 335394f4b5 | |||
| 538c593b7b | |||
| 2dc8b30d92 | |||
| d54b17ba81 | |||
| cbf260d082 | |||
| 371d54a478 | |||
| e83b137be5 | |||
| eecb795a0e | |||
| 7dc1f86d71 | |||
| 38712e2607 | |||
| eb577ea4f3 | |||
| dd56a3a8a8 | |||
| aee5fbb8c4 |
@@ -90,6 +90,10 @@ jobs:
|
||||
shell: bash
|
||||
run: make test-lua
|
||||
|
||||
- name: Run version resolution tests
|
||||
shell: bash
|
||||
run: make test-version
|
||||
|
||||
- name: Run bun tests
|
||||
shell: bash
|
||||
if: ${{ matrix.os == 'macos-latest' }}
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, fix/download-version]
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -24,46 +24,46 @@ jobs:
|
||||
- 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
|
||||
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
|
||||
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:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -86,7 +86,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 +100,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 +117,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
|
||||
@@ -138,26 +138,26 @@ jobs:
|
||||
- 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
|
||||
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
|
||||
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 +165,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 +186,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
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -217,7 +217,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 +230,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 +247,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
|
||||
@@ -282,33 +282,33 @@ jobs:
|
||||
- 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
|
||||
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
|
||||
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
|
||||
@@ -319,7 +319,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
@@ -330,13 +330,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 +347,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
|
||||
@@ -367,6 +367,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Lua
|
||||
uses: leafo/gh-actions-lua@v12
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -432,7 +435,7 @@ jobs:
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: ./scripts/determine-version.sh
|
||||
run: lua scripts/determine-version.lua
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -466,11 +469,15 @@ jobs:
|
||||
needs: [build-nvim, build-c, build-mcp]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/node' || startsWith(github.ref, 'refs/tags/v')))
|
||||
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'node'))
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/download-version' || startsWith(github.ref, 'refs/tags/v')))
|
||||
|| (github.event_name == 'pull_request' && github.head_ref == 'main')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Lua
|
||||
uses: leafo/gh-actions-lua@v12
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -479,7 +486,7 @@ jobs:
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: ./scripts/determine-version.sh
|
||||
run: lua scripts/determine-version.lua
|
||||
|
||||
- name: Publish crates
|
||||
env:
|
||||
@@ -491,13 +498,16 @@ jobs:
|
||||
needs: [build-c]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/node' || startsWith(github.ref, 'refs/tags/v')))
|
||||
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'node'))
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/download-version' || startsWith(github.ref, 'refs/tags/v')))
|
||||
|| (github.event_name == 'pull_request' && github.head_ref == 'main')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Lua
|
||||
uses: leafo/gh-actions-lua@v12
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -506,7 +516,7 @@ jobs:
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: ./scripts/determine-version.sh
|
||||
run: lua scripts/determine-version.lua
|
||||
|
||||
- name: Download npm package artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -559,5 +569,5 @@ jobs:
|
||||
|
||||
cd packages/fff-node
|
||||
npm install
|
||||
npx tsc
|
||||
npm run build
|
||||
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-node (may already exist)"
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
doc/tags
|
||||
big-repo
|
||||
target/
|
||||
.archive.lua
|
||||
_*.lua
|
||||
@@ -10,7 +11,7 @@ result
|
||||
.repro/
|
||||
.wrangler/
|
||||
*.so
|
||||
big-repo/
|
||||
*.dylib
|
||||
# all the perf like utility files
|
||||
*.data
|
||||
node_modules/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
|
||||
"command": "./target/release/fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+687
-504
File diff suppressed because it is too large
Load Diff
+12
-6
@@ -10,8 +10,8 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
fff-grep = { version = "0.4.0", path = "crates/fff-grep" }
|
||||
fff-query-parser = { version = "0.4.0", path = "crates/fff-query-parser", default-features = false }
|
||||
fff-grep = { version = "0.5.1", path = "crates/fff-grep" }
|
||||
fff-query-parser = { version = "0.5.1", path = "crates/fff-query-parser", default-features = false }
|
||||
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
@@ -27,7 +27,6 @@ git2 = { version = "0.20.2", default-features = false, features = [
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
globset = "0.4"
|
||||
grep-matcher = "0.1.8"
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
@@ -35,14 +34,15 @@ mimalloc = "0.1.47"
|
||||
zlob = "1.3.0"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = "0.8.2"
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.7"
|
||||
neo_frizbee = { version = "0.9.1", features = ["match_end_col"] }
|
||||
notify = { version = "9.0.0-rc.2" }
|
||||
notify-debouncer-full = { package="fff-notify-debouncer-full", version = "0.9.0" }
|
||||
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"
|
||||
@@ -53,5 +53,11 @@ 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"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
PLENARY_DIR ?= ../plenary.nvim
|
||||
|
||||
.PHONY: build test test-rust test-lua test-bun test-node prepare-bun prepare-node set-npm-version header
|
||||
.PHONY: build test test-rust test-lua test-version test-bun test-node prepare-bun prepare-node set-npm-version header
|
||||
|
||||
all: format test lint
|
||||
|
||||
build:
|
||||
cargo build --release --features zlob
|
||||
@@ -21,6 +23,10 @@ test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
|
||||
test-version: test-setup
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/version_spec.lua" 2>&1
|
||||
|
||||
prepare-bun: build
|
||||
mkdir -p packages/fff-bun/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
|
||||
@@ -41,7 +47,7 @@ test-bun: prepare-bun
|
||||
test-node: prepare-node
|
||||
cd packages/fff-node && npm run build && node test/e2e.mjs
|
||||
|
||||
test: test-rust test-lua test-bun test-node
|
||||
test: test-rust test-lua test-version test-bun test-node
|
||||
|
||||
# Update version in a package.json, including optionalDependencies.
|
||||
# Usage: make set-npm-version PKG=packages/fff-bun VERSION=1.0.0-nightly.abc1234
|
||||
|
||||
@@ -112,8 +112,12 @@ 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,
|
||||
@@ -285,6 +289,7 @@ require('fff').setup({
|
||||
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
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -4,6 +4,8 @@ extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
|
||||
[default.extend-words]
|
||||
noice = "noice"
|
||||
fo = "fo"
|
||||
ba = "ba"
|
||||
ue = "ue"
|
||||
|
||||
[default]
|
||||
extend-ignore-re = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-c"
|
||||
version = "0.4.2"
|
||||
version = "0.5.2"
|
||||
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.4.0" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.4.2" }
|
||||
fff = { package = "fff-search", path = "../fff-core" , version = "0.5.1" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.5.2" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -79,16 +79,16 @@ pub struct FffFileItem {
|
||||
impl From<&FileItem> for FffFileItem {
|
||||
fn from(item: &FileItem) -> Self {
|
||||
FffFileItem {
|
||||
path: cstring_new(&item.path.to_string_lossy()),
|
||||
relative_path: cstring_new(&item.relative_path),
|
||||
file_name: cstring_new(&item.file_name),
|
||||
path: cstring_new(item.path_str()),
|
||||
relative_path: cstring_new(item.relative_path()),
|
||||
file_name: cstring_new(item.file_name()),
|
||||
git_status: cstring_new(format_git_status(item.git_status)),
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
access_frecency_score: item.access_frecency_score,
|
||||
modification_frecency_score: item.modification_frecency_score,
|
||||
total_frecency_score: item.total_frecency_score,
|
||||
is_binary: item.is_binary,
|
||||
access_frecency_score: item.access_frecency_score as i64,
|
||||
modification_frecency_score: item.modification_frecency_score as i64,
|
||||
total_frecency_score: item.total_frecency_score() as i64,
|
||||
is_binary: item.is_binary(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,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 +141,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),
|
||||
}
|
||||
@@ -312,9 +314,9 @@ impl FffGrepMatch {
|
||||
};
|
||||
|
||||
FffGrepMatch {
|
||||
path: cstring_new(&file.path.to_string_lossy()),
|
||||
relative_path: cstring_new(&file.relative_path),
|
||||
file_name: cstring_new(&file.file_name),
|
||||
path: cstring_new(file.path_str()),
|
||||
relative_path: cstring_new(file.relative_path()),
|
||||
file_name: cstring_new(file.file_name()),
|
||||
git_status: cstring_new(format_git_status(file.git_status)),
|
||||
line_content: cstring_new(&m.line_content),
|
||||
match_ranges,
|
||||
@@ -322,9 +324,9 @@ impl FffGrepMatch {
|
||||
context_after,
|
||||
size: file.size,
|
||||
modified: file.modified,
|
||||
total_frecency_score: file.total_frecency_score,
|
||||
access_frecency_score: file.access_frecency_score,
|
||||
modification_frecency_score: file.modification_frecency_score,
|
||||
total_frecency_score: file.total_frecency_score() as i64,
|
||||
access_frecency_score: file.access_frecency_score as i64,
|
||||
modification_frecency_score: file.modification_frecency_score as i64,
|
||||
line_number: m.line_number,
|
||||
byte_offset: m.byte_offset,
|
||||
col: m.col as u32,
|
||||
@@ -333,7 +335,7 @@ impl FffGrepMatch {
|
||||
context_after_count,
|
||||
fuzzy_score,
|
||||
has_fuzzy_score,
|
||||
is_binary: file.is_binary,
|
||||
is_binary: file.is_binary(),
|
||||
is_definition: m.is_definition,
|
||||
}
|
||||
}
|
||||
@@ -515,15 +517,23 @@ impl FffResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan progress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scan progress returned by `fff_get_scan_progress`.
|
||||
///
|
||||
/// The caller must free this with `fff_free_scan_progress`.
|
||||
#[repr(C)]
|
||||
pub struct FffScanProgress {
|
||||
pub scanned_files_count: u64,
|
||||
pub is_scanning: bool,
|
||||
pub is_watcher_ready: bool,
|
||||
pub is_warmup_complete: bool,
|
||||
}
|
||||
|
||||
impl From<fff::file_picker::ScanProgress> for FffScanProgress {
|
||||
fn from(p: fff::file_picker::ScanProgress) -> Self {
|
||||
Self {
|
||||
scanned_files_count: p.scanned_files_count as u64,
|
||||
is_scanning: p.is_scanning,
|
||||
is_watcher_ready: p.is_watcher_ready,
|
||||
is_warmup_complete: p.is_warmup_complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-73
@@ -24,9 +24,10 @@
|
||||
|
||||
use std::ffi::{CStr, CString, c_char, c_void};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use fff::shared::SharedQueryTracker;
|
||||
|
||||
mod ffi_types;
|
||||
|
||||
use fff::file_picker::FilePicker;
|
||||
@@ -45,7 +46,7 @@ use ffi_types::{
|
||||
struct FffInstance {
|
||||
picker: SharedPicker,
|
||||
frecency: SharedFrecency,
|
||||
query_tracker: Arc<RwLock<Option<QueryTracker>>>,
|
||||
query_tracker: SharedQueryTracker,
|
||||
}
|
||||
|
||||
/// Helper to convert C string to Rust &str.
|
||||
@@ -135,9 +136,9 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
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 = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let query_tracker: Arc<RwLock<Option<QueryTracker>>> = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
let query_tracker = SharedQueryTracker::default();
|
||||
|
||||
// Initialize frecency tracker if path is provided
|
||||
if let Some(ref frecency_path) = frecency_path {
|
||||
@@ -147,19 +148,10 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
|
||||
match FrecencyTracker::new(frecency_path, use_unsafe_no_lock) {
|
||||
Ok(tracker) => {
|
||||
let mut guard = match shared_frecency.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!("Failed to acquire frecency lock: {}", e));
|
||||
}
|
||||
};
|
||||
*guard = Some(tracker);
|
||||
drop(guard);
|
||||
let _ = FrecencyTracker::spawn_gc(
|
||||
Arc::clone(&shared_frecency),
|
||||
frecency_path.clone(),
|
||||
use_unsafe_no_lock,
|
||||
);
|
||||
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);
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
|
||||
}
|
||||
@@ -173,16 +165,9 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
|
||||
match QueryTracker::new(history_path, use_unsafe_no_lock) {
|
||||
Ok(tracker) => {
|
||||
let mut guard = match query_tracker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!(
|
||||
"Failed to acquire query tracker lock: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
};
|
||||
*guard = Some(tracker);
|
||||
if let Err(e) = query_tracker.init(tracker) {
|
||||
return FffResult::err(&format!("Failed to acquire query tracker lock: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init query tracker db: {}", e)),
|
||||
}
|
||||
@@ -196,11 +181,15 @@ pub unsafe extern "C" fn fff_create_instance(
|
||||
|
||||
// Initialize file picker (writes directly into shared_picker)
|
||||
if let Err(e) = FilePicker::new_with_shared_state(
|
||||
base_path_str,
|
||||
warmup_mmap_cache,
|
||||
mode,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path_str,
|
||||
warmup_mmap_cache,
|
||||
mode,
|
||||
cache_budget: None,
|
||||
..Default::default()
|
||||
},
|
||||
) {
|
||||
return FffResult::err(&format!("Failed to init file picker: {}", e));
|
||||
}
|
||||
@@ -401,10 +390,10 @@ 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,
|
||||
};
|
||||
|
||||
let result =
|
||||
fff::grep::grep_search(picker.get_files(), &parsed, &options, picker.cache_budget());
|
||||
let result = picker.grep(&parsed, &options);
|
||||
let grep_result = FffGrepResult::from_core(&result);
|
||||
FffResult::ok_handle(grep_result as *mut c_void)
|
||||
}
|
||||
@@ -503,14 +492,19 @@ 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,
|
||||
};
|
||||
|
||||
let overlay_guard = picker.bigram_overlay().map(|o| o.read());
|
||||
let result = fff::multi_grep_search(
|
||||
picker.get_files(),
|
||||
&patterns,
|
||||
constraint_refs,
|
||||
&options,
|
||||
picker.cache_budget(),
|
||||
picker.bigram_index(),
|
||||
overlay_guard.as_deref(),
|
||||
None,
|
||||
);
|
||||
let grep_result = FffGrepResult::from_core(&result);
|
||||
FffResult::ok_handle(grep_result as *mut c_void)
|
||||
@@ -582,11 +576,7 @@ pub unsafe extern "C" fn fff_get_scan_progress(fff_handle: *mut c_void) -> *mut
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
let progress = picker.get_scan_progress();
|
||||
let result = Box::into_raw(Box::new(FffScanProgress {
|
||||
scanned_files_count: progress.scanned_files_count as u64,
|
||||
is_scanning: progress.is_scanning,
|
||||
}));
|
||||
let result = Box::into_raw(Box::new(FffScanProgress::from(picker.get_scan_progress())));
|
||||
FffResult::ok_handle(result as *mut c_void)
|
||||
}
|
||||
|
||||
@@ -598,39 +588,34 @@ pub unsafe extern "C" fn fff_get_scan_progress(fff_handle: *mut c_void) -> *mut
|
||||
pub unsafe extern "C" fn fff_wait_for_scan(
|
||||
fff_handle: *mut c_void,
|
||||
timeout_ms: u64,
|
||||
) -> *mut FffResult {
|
||||
let FffInstance { picker, .. } = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let completed = picker.wait_for_scan(Duration::from_millis(timeout_ms));
|
||||
FffResult::ok_int(completed as i64)
|
||||
}
|
||||
|
||||
/// Wait for the background file watcher to be ready.
|
||||
///
|
||||
/// ## Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create_instance`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_wait_for_watcher(
|
||||
fff_handle: *mut c_void,
|
||||
timeout_ms: u64,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let scan_signal = {
|
||||
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"),
|
||||
};
|
||||
|
||||
picker.scan_signal()
|
||||
};
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let start = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start.elapsed() >= timeout {
|
||||
return FffResult::ok_int(0);
|
||||
}
|
||||
std::thread::sleep(sleep_duration);
|
||||
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
FffResult::ok_int(1)
|
||||
let completed = inst
|
||||
.picker
|
||||
.wait_for_watcher(Duration::from_millis(timeout_ms));
|
||||
FffResult::ok_int(completed as i64)
|
||||
}
|
||||
|
||||
/// Restart indexing in a new directory.
|
||||
@@ -680,11 +665,15 @@ pub unsafe extern "C" fn fff_restart_index(
|
||||
drop(guard);
|
||||
|
||||
match FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
warmup_caches,
|
||||
mode,
|
||||
Arc::clone(&inst.picker),
|
||||
Arc::clone(&inst.frecency),
|
||||
inst.picker.clone(),
|
||||
inst.frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: canonical_path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: warmup_caches,
|
||||
mode,
|
||||
cache_budget: None,
|
||||
..Default::default()
|
||||
},
|
||||
) {
|
||||
Ok(()) => FffResult::ok_empty(),
|
||||
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
|
||||
@@ -702,7 +691,7 @@ pub unsafe extern "C" fn fff_refresh_git_status(fff_handle: *mut c_void) -> *mut
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
match FilePicker::refresh_git_status(&inst.picker, &inst.frecency) {
|
||||
match inst.picker.refresh_git_status(&inst.frecency) {
|
||||
Ok(count) => FffResult::ok_int(count as i64),
|
||||
Err(e) => FffResult::err(&format!("Failed to refresh git status: {}", e)),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-search"
|
||||
version = "0.4.2"
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
|
||||
@@ -14,6 +14,9 @@ crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Call mi_collect(true) after large allocator churn (bigram build).
|
||||
# Requires mimalloc to be the global allocator (linked by fff-nvim).
|
||||
mimalloc-collect = ["dep:libmimalloc-sys"]
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
@@ -24,18 +27,18 @@ rayon = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
fff-query-parser = { workspace = true , version = "0.4.2" }
|
||||
fff-query-parser = { workspace = true , version = "0.5.2" }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
libc = "0.2"
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
fff-grep = { workspace = true , version = "0.4.2" }
|
||||
fff-grep = { workspace = true , version = "0.5.2" }
|
||||
aho-corasick = "1"
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
@@ -48,6 +51,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"] }
|
||||
@@ -55,6 +59,7 @@ toml = "0.8"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { workspace = true, optional = true }
|
||||
libmimalloc-sys = { version = "0.1", optional = true, features = ["extended"] }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
@@ -63,3 +68,15 @@ dunce = { workspace = true }
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
|
||||
[[bench]]
|
||||
name = "parse_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "bigram_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "memmem_bench"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_search::bigram_filter::{BigramFilter, BigramIndexBuilder};
|
||||
|
||||
/// Build a realistic bigram index for benchmarking.
|
||||
/// Simulates a large repo by generating varied content per file.
|
||||
fn build_test_index(file_count: usize) -> BigramFilter {
|
||||
let builder = BigramIndexBuilder::new(file_count);
|
||||
let skip_builder = BigramIndexBuilder::new(file_count);
|
||||
|
||||
for i in 0..file_count {
|
||||
// Generate varied content so we get a mix of sparse and dense columns
|
||||
let content = format!(
|
||||
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // module {i}"
|
||||
);
|
||||
builder.add_file_content(&skip_builder, i, content.as_bytes());
|
||||
}
|
||||
|
||||
let mut index = builder.compress(None);
|
||||
let skip_index = skip_builder.compress(Some(12));
|
||||
index.set_skip_index(skip_index);
|
||||
index
|
||||
}
|
||||
|
||||
fn bench_bigram_query(c: &mut Criterion) {
|
||||
let file_counts = [10_000, 100_000, 500_000];
|
||||
|
||||
for &file_count in &file_counts {
|
||||
let index = build_test_index(file_count);
|
||||
eprintln!(
|
||||
"Index ({} files): {} columns",
|
||||
file_count,
|
||||
index.columns_used(),
|
||||
);
|
||||
|
||||
let mut group = c.benchmark_group(format!("bigram_query_{file_count}"));
|
||||
group.sample_size(500);
|
||||
|
||||
let queries: &[(&str, &[u8])] = &[
|
||||
("short_2char", b"st"),
|
||||
("medium_6char", b"struct"),
|
||||
("long_14char", b"let controller"),
|
||||
("multi_word", b"fn process"),
|
||||
];
|
||||
|
||||
for (name, query) in queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter(|| {
|
||||
let result = index.query(black_box(q));
|
||||
black_box(&result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_bigram_is_candidate(c: &mut Criterion) {
|
||||
let index = build_test_index(500_000);
|
||||
let candidates = match index.query(b"struct") {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// All bigrams ubiquitous at this size — skip candidate benches
|
||||
eprintln!("Skipping is_candidate bench: query returned None (all bigrams ubiquitous)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
c.bench_function("is_candidate_500k", |b| {
|
||||
b.iter(|| {
|
||||
let mut count = 0u32;
|
||||
for i in 0..500_000 {
|
||||
if BigramFilter::is_candidate(black_box(&candidates), i) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
black_box(count)
|
||||
});
|
||||
});
|
||||
|
||||
c.bench_function("count_candidates_500k", |b| {
|
||||
b.iter(|| BigramFilter::count_candidates(black_box(&candidates)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_bigram_build(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("bigram_build");
|
||||
group.sample_size(10);
|
||||
|
||||
let file_counts = [10_000, 100_000];
|
||||
|
||||
for &file_count in &file_counts {
|
||||
// Pre-generate content so we only measure index building
|
||||
let contents: Vec<String> = (0..file_count)
|
||||
.map(|i| {
|
||||
format!(
|
||||
"struct File{i} {{ fn process() {{ let controller = read(path); }} }} // mod {i}"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("build_and_compress", file_count),
|
||||
&file_count,
|
||||
|b, &fc| {
|
||||
b.iter(|| {
|
||||
let builder = BigramIndexBuilder::new(fc);
|
||||
let skip_builder = BigramIndexBuilder::new(fc);
|
||||
for (i, content) in contents.iter().enumerate() {
|
||||
builder.add_file_content(&skip_builder, i, content.as_bytes());
|
||||
}
|
||||
let index = builder.compress(None);
|
||||
black_box(index.columns_used())
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_bigram_query,
|
||||
bench_bigram_is_candidate,
|
||||
bench_bigram_build,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,101 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_search::case_insensitive_memmem;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load real source files from the repository as benchmark haystacks.
|
||||
/// Falls back to concatenating all .rs files under crates/ if specific files are missing.
|
||||
fn load_real_files() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR"); // crates/fff-core
|
||||
let repo_root = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
|
||||
let files: &[(&str, &str)] = &[
|
||||
("grep.rs/80KB", "crates/fff-core/src/grep.rs"),
|
||||
("file_picker.rs/53KB", "crates/fff-core/src/file_picker.rs"),
|
||||
("picker_ui.lua/96KB", "lua/fff/picker_ui.lua"),
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
for &(label, rel_path) in files {
|
||||
let full_path = repo_root.join(rel_path);
|
||||
if let Ok(data) = std::fs::read(&full_path) {
|
||||
result.push((label, data));
|
||||
}
|
||||
}
|
||||
|
||||
// Also create a large synthetic file by concatenating all three
|
||||
if result.len() == 3 {
|
||||
let mut combined = Vec::new();
|
||||
for (_, data) in &result {
|
||||
combined.extend_from_slice(data);
|
||||
}
|
||||
// Repeat to get ~1MB
|
||||
let base = combined.clone();
|
||||
while combined.len() < 1024 * 1024 {
|
||||
combined.extend_from_slice(&base);
|
||||
}
|
||||
combined.truncate(1024 * 1024);
|
||||
result.push(("combined/1MB", combined));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn bench_memmem(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("case_insensitive_memmem");
|
||||
|
||||
let files = load_real_files();
|
||||
assert!(!files.is_empty(), "No source files found for benchmarking");
|
||||
|
||||
// Needles chosen to exercise different false-positive rates:
|
||||
//
|
||||
// "hit" needles: strings that actually appear in these source files.
|
||||
// "miss" needles: strings with common first-bytes (lots of false positives
|
||||
// for memchr2) but that don't exist in any of the files.
|
||||
let needles: &[(&str, &[u8])] = &[
|
||||
// Hits — real identifiers from the codebase
|
||||
("short/hit/fn", b"fn"),
|
||||
("short/hit/self", b"self"),
|
||||
("medium/hit", b"search_file"),
|
||||
("long/hit", b"content_cache_budget"),
|
||||
// Misses — common first-bytes, guaranteed not in source
|
||||
("short/miss", b"zqxjv"),
|
||||
("medium/miss", b"fluxcapacitor"),
|
||||
("long/miss", b"quantum_entanglement_resolver"),
|
||||
];
|
||||
|
||||
for (file_label, haystack) in &files {
|
||||
for &(needle_label, needle) in needles {
|
||||
let needle_lower: Vec<u8> = needle.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
let id = format!("{file_label}/{needle_label}");
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("packed_pair", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search_packed_pair(h, n)));
|
||||
},
|
||||
);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("memchr2_search", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search(h, n)));
|
||||
},
|
||||
);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("scalar_baseline", &id),
|
||||
&(haystack, &needle_lower),
|
||||
|b, &(h, n)| {
|
||||
b.iter(|| black_box(case_insensitive_memmem::search_scalar(h, n)));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_memmem);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,180 @@
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
|
||||
use fff_query_parser::*;
|
||||
|
||||
fn bench_parse_simple(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_simple_text", |b| {
|
||||
b.iter(|| parser.parse(black_box("hello world")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_text_with_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("name *.rs")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_complex(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_complex_mixed", |b| {
|
||||
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_multiple_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_realistic_queries(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let queries = vec![
|
||||
"file",
|
||||
"test",
|
||||
"mod.rs",
|
||||
"src/*.rs",
|
||||
"lib test",
|
||||
"*.rs !test",
|
||||
"src/lib/*.rs",
|
||||
"/src/ name",
|
||||
"status:modified *.rs",
|
||||
"type:rust test !node_modules",
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("realistic_queries");
|
||||
for query in queries.iter() {
|
||||
group.throughput(Throughput::Bytes(query.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_parse_various_lengths(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let short = "*.rs";
|
||||
let medium = "src name *.rs !test";
|
||||
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
|
||||
let very_long =
|
||||
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
|
||||
|
||||
let mut group = c.benchmark_group("query_lengths");
|
||||
|
||||
group.throughput(Throughput::Bytes(short.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(medium.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(long.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(very_long.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("very_long", very_long.len()),
|
||||
&very_long,
|
||||
|b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_config_comparison(c: &mut Criterion) {
|
||||
let file_picker = QueryParser::new(FileSearchConfig);
|
||||
let grep = QueryParser::new(GrepConfig);
|
||||
|
||||
let query = "src name *.rs !test";
|
||||
|
||||
let mut group = c.benchmark_group("config_comparison");
|
||||
|
||||
group.bench_function("file_picker_config", |b| {
|
||||
b.iter(|| file_picker.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.bench_function("grep_config", |b| {
|
||||
b.iter(|| grep.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_constraint_types(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let mut group = c.benchmark_group("constraint_types");
|
||||
|
||||
group.bench_function("extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("exclude", |b| {
|
||||
b.iter(|| parser.parse(black_box("!test")));
|
||||
});
|
||||
|
||||
group.bench_function("path_segment", |b| {
|
||||
b.iter(|| parser.parse(black_box("/src/")));
|
||||
});
|
||||
|
||||
group.bench_function("git_status", |b| {
|
||||
b.iter(|| parser.parse(black_box("status:modified")));
|
||||
});
|
||||
|
||||
group.bench_function("file_type", |b| {
|
||||
b.iter(|| parser.parse(black_box("type:rust")));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_worst_case(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
// Worst case: many constraints that all need to be checked
|
||||
let worst_case = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
|
||||
|
||||
c.bench_function("worst_case_many_text_tokens", |b| {
|
||||
b.iter(|| parser.parse(black_box(worst_case)));
|
||||
});
|
||||
|
||||
// Many constraints
|
||||
let many_constraints = "*.rs *.toml *.md *.txt *.js *.ts *.jsx *.tsx *.vue *.svelte";
|
||||
|
||||
c.bench_function("worst_case_many_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box(many_constraints)));
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_parse_simple,
|
||||
bench_parse_complex,
|
||||
bench_parse_realistic_queries,
|
||||
bench_parse_various_lengths,
|
||||
bench_config_comparison,
|
||||
bench_constraint_types,
|
||||
bench_worst_case,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -1,15 +1,16 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::{FFFMode, FilePicker};
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::shared::{SharedFrecency, SharedPicker};
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify::{Config, EventKind, EventKindMask, RecursiveMode};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
|
||||
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};
|
||||
|
||||
@@ -36,7 +37,6 @@ pub struct BackgroundWatcher {
|
||||
|
||||
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
|
||||
/// Minimum seconds between frecency tracks of the same file in AI mode.
|
||||
/// Prevents score inflation from rapid burst edits by AI agents.
|
||||
const AI_MODE_COOLDOWN_SECS: u64 = 5 * 60;
|
||||
@@ -48,6 +48,7 @@ impl BackgroundWatcher {
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
watch_dirs: Vec<PathBuf>,
|
||||
) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing background watcher for path: {}, mode: {:?}",
|
||||
@@ -55,8 +56,21 @@ impl BackgroundWatcher {
|
||||
mode,
|
||||
);
|
||||
|
||||
let debouncer =
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
|
||||
let (watch_tx, watch_rx) = mpsc::channel::<PathBuf>();
|
||||
|
||||
// Clone shared state for the owner thread
|
||||
let owner_picker = shared_picker.clone();
|
||||
let owner_git_workdir = git_workdir.clone();
|
||||
|
||||
let debouncer = Self::create_debouncer(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
mode,
|
||||
watch_dirs,
|
||||
watch_tx,
|
||||
)?;
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
@@ -68,7 +82,23 @@ impl BackgroundWatcher {
|
||||
let owner_thread = std::thread::Builder::new()
|
||||
.name("fff-watcher-owner".into())
|
||||
.spawn(move || {
|
||||
let mut debouncer = debouncer;
|
||||
while !stop_clone.load(Ordering::Acquire) {
|
||||
// Process pending watch requests from the event handler
|
||||
// (new directories that need to be watched).
|
||||
while let Ok(dir) = watch_rx.try_recv() {
|
||||
match debouncer.watch(dir.as_path(), RecursiveMode::NonRecursive) {
|
||||
Ok(()) => {
|
||||
debug!("Added watch for new directory: {}", dir.display());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to watch new directory {}: {}", dir.display(), e);
|
||||
}
|
||||
}
|
||||
// Files/dirs created before the watch was set up won't
|
||||
// generate inotify events. Scan and inject them now.
|
||||
scan_new_directory(&dir, &mut debouncer, &owner_picker, &owner_git_workdir);
|
||||
}
|
||||
std::thread::park_timeout(Duration::from_secs(1));
|
||||
}
|
||||
// Debouncer::stop() joins the debouncer's event thread, then
|
||||
@@ -95,11 +125,17 @@ impl BackgroundWatcher {
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
mode: FFFMode,
|
||||
watch_dirs: Vec<PathBuf>,
|
||||
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);
|
||||
|
||||
let git_workdir_for_handler = git_workdir.clone();
|
||||
let mut debouncer = new_debouncer_opt(
|
||||
@@ -108,6 +144,30 @@ impl BackgroundWatcher {
|
||||
{
|
||||
move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
// Detect newly created directories and request NonRecursive
|
||||
// watches on them so we see files created inside.
|
||||
// Skip gitignored directories (e.g. node_modules/) to
|
||||
// avoid re-inflating the inotify watch set.
|
||||
let repo = git_workdir_for_handler
|
||||
.as_ref()
|
||||
.and_then(|p| Repository::open(p).ok());
|
||||
for debounced_event in &events {
|
||||
if matches!(
|
||||
debounced_event.event.kind,
|
||||
EventKind::Create(_)
|
||||
| EventKind::Modify(notify::event::ModifyKind::Name(_))
|
||||
) {
|
||||
for path in &debounced_event.event.paths {
|
||||
if path.is_dir()
|
||||
&& !is_git_file(path)
|
||||
&& !is_path_ignored(path, &repo)
|
||||
{
|
||||
let _ = watch_tx.send(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle_debounced_events(
|
||||
events,
|
||||
&git_workdir_for_handler,
|
||||
@@ -129,41 +189,33 @@ 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);
|
||||
// Watch all directories NonRecursively. The watch_dirs are derived from
|
||||
// the already-scanned file list so they respect .gitignore at every depth.
|
||||
// On Linux (inotify) RecursiveMode::Recursive creates one watch per subdirectory
|
||||
// including gitignored ones like node_modules/, which wastes kernel resources.
|
||||
// NonRecursive watches only the directories that actually contain indexed files.
|
||||
//
|
||||
// New directories created at runtime are detected via Create events on the
|
||||
// parent and dynamically added by the owner thread via the watch_tx channel.
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
|
||||
|
||||
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
|
||||
);
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
} 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);
|
||||
}
|
||||
for dir in &watch_dirs {
|
||||
match debouncer.watch(dir.as_path(), RecursiveMode::NonRecursive) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// Non-fatal: directory may have been removed between discovery and watch
|
||||
warn!("Failed to watch directory {}: {}", dir.display(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
// 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.).
|
||||
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
|
||||
|
||||
info!(
|
||||
"File watcher initialized for {} directories under {}",
|
||||
"File watcher initialized for {} directories (NonRecursive) under {}",
|
||||
watch_dirs.len(),
|
||||
base_path.display()
|
||||
);
|
||||
@@ -333,9 +385,9 @@ fn handle_debounced_events(
|
||||
debug!(
|
||||
"on_create_or_modify({:?}) -> Some({})",
|
||||
path,
|
||||
file.path.display()
|
||||
file.path_str()
|
||||
);
|
||||
files_to_update.push(file.path.clone());
|
||||
files_to_update.push(PathBuf::from(file.path_str()));
|
||||
}
|
||||
None => {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
@@ -416,7 +468,7 @@ fn handle_debounced_events(
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
|
||||
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
|
||||
let result = shared_picker.refresh_git_status(shared_frecency);
|
||||
if let Err(e) = result {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
@@ -468,9 +520,14 @@ fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFre
|
||||
};
|
||||
if let Err(e) = picker.trigger_rescan(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
} else {
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
return;
|
||||
}
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
|
||||
// Spawn background warmup + bigram rebuild (mirrors the initial scan's
|
||||
// post-scan phase). The write lock is still held here but the spawned
|
||||
// thread re-acquires it later — safe because the guard drops at function end.
|
||||
picker.spawn_post_rescan_rebuild(shared_picker.clone());
|
||||
}
|
||||
|
||||
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
@@ -479,11 +536,89 @@ fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there is a git repo, respect its ignore rules.
|
||||
// If there is no repo (or the check fails), include the file.
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) != Ok(true),
|
||||
None => true,
|
||||
None => {
|
||||
// No git repo — apply basic sanity filters.
|
||||
// Hidden directories are skipped by the watcher setup (hidden(true)),
|
||||
// but events can still arrive for files in known non-code directories.
|
||||
!is_non_code_directory(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_code_directory(path: &Path) -> bool {
|
||||
crate::ignore::is_non_code_directory(path)
|
||||
}
|
||||
|
||||
/// After adding a NonRecursive watch on a newly created directory, scan it for
|
||||
/// files and subdirectories that were created before the watch was set up.
|
||||
/// This closes the race where `mkdir foo && echo > foo/bar.txt` both happen
|
||||
/// before the owner thread adds a watch on `foo/`.
|
||||
fn scan_new_directory(
|
||||
dir: &Path,
|
||||
debouncer: &mut Debouncer,
|
||||
shared_picker: &SharedPicker,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
) {
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
let mut files_to_add = Vec::new();
|
||||
|
||||
collect_new_entries(dir, &repo, debouncer, &mut files_to_add);
|
||||
|
||||
if files_to_add.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
info!(
|
||||
"Scanned new directory {}: added {} files",
|
||||
dir.display(),
|
||||
files_to_add.len(),
|
||||
);
|
||||
}
|
||||
|
||||
fn collect_new_entries(
|
||||
dir: &Path,
|
||||
repo: &Option<Repository>,
|
||||
debouncer: &mut Debouncer,
|
||||
files: &mut Vec<PathBuf>,
|
||||
) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if file_type.is_dir() {
|
||||
if !is_git_file(&path) && !is_path_ignored(&path, repo) {
|
||||
let _ = debouncer.watch(&path, RecursiveMode::NonRecursive);
|
||||
collect_new_entries(&path, repo, debouncer, files);
|
||||
}
|
||||
} else if file_type.is_file() && should_include_file(&path, repo) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,38 +702,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) -> Vec<PathBuf> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let walker = WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.max_depth(Some(1))
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use ahash::AHashMap;
|
||||
|
||||
/// Maximum number of distinct bigrams tracked in the inverted index.
|
||||
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
|
||||
/// We cap at 5000 to cover all printable bigrams with margin.
|
||||
/// 5000 columns × 62.5KB (500k files) = 305MB. For 50k files: 30MB.
|
||||
const MAX_BIGRAM_COLUMNS: usize = 5000;
|
||||
|
||||
/// Sentinel value: bigram has no allocated column.
|
||||
const NO_COLUMN: u16 = u16::MAX;
|
||||
|
||||
/// Temporary sync dense builder for the bigram index.
|
||||
/// Builds from the many threads reading file contents in parallel
|
||||
pub struct BigramIndexBuilder {
|
||||
// we use lookup as atomics only in the builder because it is filled by the rayon threads
|
||||
// the actual index uses pure u16 for the allocations
|
||||
lookup: Vec<AtomicU16>,
|
||||
/// Per-column bitset data, lazily allocated via OnceLock.
|
||||
col_data: Vec<AtomicU64>,
|
||||
next_column: AtomicU16,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: AtomicUsize,
|
||||
}
|
||||
|
||||
impl BigramIndexBuilder {
|
||||
pub fn new(file_count: usize) -> Self {
|
||||
let words = file_count.div_ceil(64);
|
||||
let mut lookup = Vec::with_capacity(65536);
|
||||
lookup.resize_with(65536, || AtomicU16::new(NO_COLUMN));
|
||||
let mut col_data = Vec::with_capacity(MAX_BIGRAM_COLUMNS * words);
|
||||
col_data.resize_with(MAX_BIGRAM_COLUMNS * words, || AtomicU64::new(0));
|
||||
Self {
|
||||
lookup,
|
||||
col_data,
|
||||
next_column: AtomicU16::new(0),
|
||||
words,
|
||||
file_count,
|
||||
populated: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_or_alloc_column(&self, key: u16) -> u16 {
|
||||
let current = self.lookup[key as usize].load(Ordering::Relaxed);
|
||||
if current != NO_COLUMN {
|
||||
return current;
|
||||
}
|
||||
let new_col = self.next_column.fetch_add(1, Ordering::Relaxed);
|
||||
if new_col >= MAX_BIGRAM_COLUMNS as u16 {
|
||||
return NO_COLUMN;
|
||||
}
|
||||
|
||||
match self.lookup[key as usize].compare_exchange(
|
||||
NO_COLUMN,
|
||||
new_col,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => new_col,
|
||||
Err(existing) => existing,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn column_bitset(&self, col: u16) -> &[AtomicU64] {
|
||||
let start = col as usize * self.words;
|
||||
&self.col_data[start..start + self.words]
|
||||
}
|
||||
|
||||
pub(crate) fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
|
||||
if content.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
debug_assert!(file_idx < self.file_count);
|
||||
let word_idx = file_idx / 64;
|
||||
let bit_mask = 1u64 << (file_idx % 64);
|
||||
|
||||
// Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 bigrams with margin
|
||||
// have to fit in L1 cache
|
||||
let mut seen_consec = [0u64; 1024];
|
||||
let mut seen_skip = [0u64; 1024];
|
||||
|
||||
let bytes = content;
|
||||
let len = bytes.len();
|
||||
|
||||
// First consecutive pair (no skip bigram possible yet).
|
||||
let (a, b) = (bytes[0], bytes[1]);
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let w = key as usize >> 6;
|
||||
let bit = 1u64 << (key as usize & 63);
|
||||
seen_consec[w] |= bit;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// Main loop: consecutive (i-1, i) and skip-1 (i-2, i)
|
||||
for i in 2..len {
|
||||
let cur = bytes[i];
|
||||
|
||||
// Consecutive bigram: (bytes[i-1], bytes[i])
|
||||
let prev = bytes[i - 1];
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&cur) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16;
|
||||
let w = key as usize >> 6;
|
||||
let bit = 1u64 << (key as usize & 63);
|
||||
if seen_consec[w] & bit == 0 {
|
||||
seen_consec[w] |= bit;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip-1 bigram: (bytes[i-2], bytes[i])
|
||||
let skip_prev = bytes[i - 2];
|
||||
if (32..=126).contains(&skip_prev) && (32..=126).contains(&cur) {
|
||||
let key =
|
||||
(skip_prev.to_ascii_lowercase() as u16) << 8 | cur.to_ascii_lowercase() as u16;
|
||||
let w = key as usize >> 6;
|
||||
let bit = 1u64 << (key as usize & 63);
|
||||
if seen_skip[w] & bit == 0 {
|
||||
seen_skip[w] |= bit;
|
||||
let col = skip_builder.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
skip_builder.column_bitset(col)[word_idx]
|
||||
.fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.populated.fetch_add(1, Ordering::Relaxed);
|
||||
skip_builder.populated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> u16 {
|
||||
self.next_column
|
||||
.load(Ordering::Relaxed)
|
||||
.min(MAX_BIGRAM_COLUMNS as u16)
|
||||
}
|
||||
|
||||
/// Compress the dense builder into a compact `BigramFilter`.
|
||||
///
|
||||
/// Retains columns where the bigram appears in ≥`min_density_pct`% (or
|
||||
/// the default ~3.1% heuristic when `None`) and <90% of indexed files.
|
||||
/// Sparse columns carry too little data to justify their memory;
|
||||
/// ubiquitous columns (≥90%) are nearly all-ones and barely filter.
|
||||
pub fn compress(self, min_density_pct: Option<u32>) -> BigramFilter {
|
||||
let cols = self.columns_used() as usize;
|
||||
let words = self.words;
|
||||
let file_count = self.file_count;
|
||||
let populated = self.populated.load(Ordering::Relaxed);
|
||||
let dense_bytes = words * 8; // cost of one dense column
|
||||
|
||||
let old_lookup = self.lookup;
|
||||
let col_data = self.col_data;
|
||||
|
||||
let mut lookup: Vec<u16> = vec![NO_COLUMN; 65536];
|
||||
let mut dense_data: Vec<u64> = Vec::with_capacity(cols * words);
|
||||
let mut dense_count: usize = 0;
|
||||
|
||||
for key in 0..65536usize {
|
||||
let old_col = old_lookup[key].load(Ordering::Relaxed);
|
||||
if old_col == NO_COLUMN || old_col as usize >= cols {
|
||||
continue;
|
||||
}
|
||||
|
||||
let col_start = old_col as usize * words;
|
||||
let bitset = &col_data[col_start..col_start + words];
|
||||
|
||||
// count set bits to decide if this column is worth keeping.
|
||||
let mut popcount = 0u32;
|
||||
for column in bitset.iter().take(words) {
|
||||
popcount += column.load(Ordering::Relaxed).count_ones();
|
||||
}
|
||||
|
||||
// drop bigrams appearing in too few files
|
||||
let not_to_rare = if let Some(min_pct) = min_density_pct {
|
||||
// Percentage-based: require ≥ min_pct% of populated files.
|
||||
populated > 0 && (popcount as usize) * 100 >= populated * min_pct as usize
|
||||
} else {
|
||||
// Default: popcount ≥ words × 2 (~3.1% of files).
|
||||
(popcount as usize * 4) >= dense_bytes
|
||||
};
|
||||
|
||||
if !not_to_rare {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop ubiquitous bigrams — columns ≥90% ones carry almost no
|
||||
// filtering power and just waste memory + AND cycles.
|
||||
if populated > 0 && (popcount as usize) * 10 >= populated * 9 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dense_idx = dense_count as u16;
|
||||
lookup[key] = dense_idx;
|
||||
dense_count += 1;
|
||||
|
||||
for column in bitset.iter().take(words) {
|
||||
dense_data.push(column.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
// col_data + old_lookup dropped here — single deallocation each,
|
||||
// no fragmentation.
|
||||
|
||||
BigramFilter {
|
||||
lookup,
|
||||
dense_data,
|
||||
dense_count,
|
||||
words,
|
||||
file_count,
|
||||
populated,
|
||||
skip_index: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for BigramIndexBuilder {}
|
||||
unsafe impl Sync for BigramIndexBuilder {}
|
||||
|
||||
/// Inverted bigram index with optional "skip-1" extension
|
||||
/// Copmressed into bitset for minimal usage, the layout of this struct actually matters
|
||||
#[derive(Debug)]
|
||||
pub struct BigramFilter {
|
||||
lookup: Vec<u16>,
|
||||
/// Flat buffer of all dense column data laid out at fixed stride `words`.
|
||||
/// Column `i` starts at `i * words`.
|
||||
dense_data: Vec<u64>, // do not try to change this to u8 it has to be wordsize
|
||||
dense_count: usize,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: usize,
|
||||
/// Optional skip-1 bigram index (stride 2). Built from character pairs
|
||||
/// at distance 2, e.g. "ABCDE" → (A,C),(B,D),(C,E). ANDead with the
|
||||
/// consecutive bigram candidates during query to dramatically reduce
|
||||
/// false positives.
|
||||
skip_index: Option<Box<BigramFilter>>,
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise AND of two equal-length bitsets.
|
||||
// Auto vectorized (don't touch)
|
||||
#[inline]
|
||||
fn bitset_and(result: &mut [u64], bitset: &[u64]) {
|
||||
result
|
||||
.iter_mut()
|
||||
.zip(bitset.iter())
|
||||
.for_each(|(r, b)| *r &= *b);
|
||||
}
|
||||
|
||||
impl BigramFilter {
|
||||
/// AND the posting lists for all query bigrams (consecutive + skip).
|
||||
/// Returns None if no query bigrams are tracked.
|
||||
pub fn query(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
if pattern.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
let mut prev = pattern[0];
|
||||
for &b in &pattern[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
// SAFETY: compress() guarantees offset + words <= dense_data.len()
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
|
||||
// strid-1 bigrams
|
||||
if let Some(skip) = &self.skip_index
|
||||
&& pattern.len() >= 3
|
||||
&& let Some(skip_candidates) = skip.query_skip(pattern)
|
||||
{
|
||||
bitset_and(&mut result, &skip_candidates);
|
||||
has_filter = true;
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Query using stride-2 bigrams from the pattern.
|
||||
/// For "ABCDE" queries with keys (A,C), (B,D), (C,E).
|
||||
fn query_skip(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
for i in 0..pattern.len().saturating_sub(2) {
|
||||
let a = pattern[i];
|
||||
let b = pattern[i + 2];
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Attach a skip-1 bigram index for tighter candidate filtering.
|
||||
pub fn set_skip_index(&mut self, skip: BigramFilter) {
|
||||
self.skip_index = Some(Box::new(skip));
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_candidate(candidates: &[u64], file_idx: usize) -> bool {
|
||||
let word = file_idx / 64;
|
||||
let bit = file_idx % 64;
|
||||
word < candidates.len() && candidates[word] & (1u64 << bit) != 0
|
||||
}
|
||||
|
||||
pub fn count_candidates(candidates: &[u64]) -> usize {
|
||||
candidates.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated > 0
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.file_count
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> usize {
|
||||
self.dense_count
|
||||
}
|
||||
|
||||
/// Total heap bytes used by this index (lookup + dense data + skip).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let lookup_bytes = self.lookup.len() * std::mem::size_of::<u16>();
|
||||
let dense_bytes = self.dense_data.len() * std::mem::size_of::<u64>();
|
||||
let skip_bytes = self.skip_index.as_ref().map_or(0, |s| s.heap_bytes());
|
||||
lookup_bytes + dense_bytes + skip_bytes
|
||||
}
|
||||
|
||||
/// Check whether a bigram key is present in this index.
|
||||
pub fn has_key(&self, key: u16) -> bool {
|
||||
self.lookup[key as usize] != NO_COLUMN
|
||||
}
|
||||
|
||||
/// Raw lookup table (65536 entries mapping bigram key → column index).
|
||||
pub fn lookup(&self) -> &[u16] {
|
||||
&self.lookup
|
||||
}
|
||||
|
||||
/// Flat dense bitset data at fixed stride `words`.
|
||||
pub fn dense_data(&self) -> &[u64] {
|
||||
&self.dense_data
|
||||
}
|
||||
|
||||
/// Number of u64 words per column (= ceil(file_count / 64)).
|
||||
pub fn words(&self) -> usize {
|
||||
self.words
|
||||
}
|
||||
|
||||
/// Number of dense columns retained after compression.
|
||||
pub fn dense_count(&self) -> usize {
|
||||
self.dense_count
|
||||
}
|
||||
|
||||
/// Number of files that contributed content to the index.
|
||||
pub fn populated(&self) -> usize {
|
||||
self.populated
|
||||
}
|
||||
|
||||
/// Reference to the optional skip-1 bigram sub-index.
|
||||
pub fn skip_index(&self) -> Option<&BigramFilter> {
|
||||
self.skip_index.as_deref()
|
||||
}
|
||||
|
||||
/// Create a new bigram filter from the internal data
|
||||
pub fn new(
|
||||
lookup: Vec<u16>,
|
||||
dense_data: Vec<u64>,
|
||||
dense_count: usize,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
lookup,
|
||||
dense_data,
|
||||
dense_count,
|
||||
words,
|
||||
file_count,
|
||||
populated,
|
||||
skip_index: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_bigrams(content: &[u8]) -> Vec<u16> {
|
||||
if content.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Use a flat bitset (65536 bits = 8 KB) for dedup — faster than HashSet.
|
||||
let mut seen = vec![0u64; 1024]; // 1024 * 64 = 65536 bits
|
||||
let mut bigrams = Vec::new();
|
||||
|
||||
let mut prev = content[0];
|
||||
for &b in &content[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let word = key as usize / 64;
|
||||
let bit = 1u64 << (key as usize % 64);
|
||||
if seen[word] & bit == 0 {
|
||||
seen[word] |= bit;
|
||||
bigrams.push(key);
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
bigrams
|
||||
}
|
||||
|
||||
/// Modified and added files store their own bigram sets. Deleted files are
|
||||
/// tombstoned in a bitset so they can be excluded from base query results.
|
||||
/// This overlay is updated by the background watcher on every file event
|
||||
/// and cleared when the base index is rebuilt.
|
||||
#[derive(Debug)]
|
||||
pub struct BigramOverlay {
|
||||
/// Per-file bigram sets for files modified since the base was built.
|
||||
/// Key = file index in the base `Vec<FileItem>`.
|
||||
modified: AHashMap<usize, Vec<u16>>,
|
||||
|
||||
/// Tombstone bitset — one bit per base file. Set bits are excluded
|
||||
/// from base query results.
|
||||
tombstones: Vec<u64>,
|
||||
|
||||
/// Original files count this overlay was created for.
|
||||
base_file_count: usize,
|
||||
}
|
||||
|
||||
impl BigramOverlay {
|
||||
pub(crate) fn new(base_file_count: usize) -> Self {
|
||||
let words = base_file_count.div_ceil(64);
|
||||
Self {
|
||||
modified: AHashMap::new(),
|
||||
tombstones: vec![0u64; words],
|
||||
base_file_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn modify_file(&mut self, file_idx: usize, content: &[u8]) {
|
||||
self.modified.insert(file_idx, extract_bigrams(content));
|
||||
}
|
||||
|
||||
pub(crate) fn delete_file(&mut self, file_idx: usize) {
|
||||
if file_idx < self.base_file_count {
|
||||
let word = file_idx / 64;
|
||||
self.tombstones[word] |= 1u64 << (file_idx % 64);
|
||||
}
|
||||
self.modified.remove(&file_idx);
|
||||
}
|
||||
|
||||
/// Return base file indices of modified files whose bigrams match ALL
|
||||
/// of the given `pattern_bigrams`.
|
||||
pub(crate) fn query_modified(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
|
||||
if pattern_bigrams.is_empty() {
|
||||
return self.modified.keys().copied().collect();
|
||||
}
|
||||
self.modified
|
||||
.iter()
|
||||
.filter_map(|(&file_idx, bigrams)| {
|
||||
pattern_bigrams
|
||||
.iter()
|
||||
.all(|pb| bigrams.contains(pb))
|
||||
.then_some(file_idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of base files this overlay was created for.
|
||||
pub(crate) fn base_file_count(&self) -> usize {
|
||||
self.base_file_count
|
||||
}
|
||||
|
||||
/// Get the tombstone bitset for clearing base candidates.
|
||||
pub(crate) fn tombstones(&self) -> &[u64] {
|
||||
&self.tombstones
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,662 @@
|
||||
//! SIMD-accelerated case-insensitive substring search.
|
||||
//!
|
||||
//! Implementations (fastest → simplest):
|
||||
//! - `search_packed_pair`: AVX2 packed-pair scan (two rare bytes at known offsets)
|
||||
//! - `search`: memchr2 first-byte scan + verify
|
||||
//!
|
||||
//! The packed-pair approach mirrors what `memchr::memmem` does internally for
|
||||
//! case-sensitive search — pick two rare bytes from the needle, SIMD-scan for
|
||||
//! both simultaneously, verify candidates. This gives quadratic selectivity
|
||||
//! over the single-byte memchr2 approach.
|
||||
|
||||
// this is stolen from the memchr2 crate
|
||||
const BYTE_FREQUENCIES: [u8; 256] = [
|
||||
55, 52, 51, 50, 49, 48, 47, 46, 45, 103, 242, 66, 67, 229, 44, 43, // 0x00
|
||||
42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 56, 32, 31, 30, 29, 28, // 0x10
|
||||
255, 148, 164, 149, 136, 160, 155, 173, 221, 222, 134, 122, 232, 202, 215, 224, // 0x20
|
||||
208, 220, 204, 187, 183, 179, 177, 168, 178, 200, 226, 195, 154, 184, 174, 126, // 0x30
|
||||
120, 191, 157, 194, 170, 189, 162, 161, 150, 193, 142, 137, 171, 176, 185,
|
||||
167, // 0x40 A-O
|
||||
186, 112, 175, 192, 188, 156, 140, 143, 123, 133, 128, 147, 138, 146, 114,
|
||||
223, // 0x50 P-_
|
||||
151, 249, 216, 238, 236, 253, 227, 218, 230, 247, 135, 180, 241, 233, 246,
|
||||
244, // 0x60 a-o
|
||||
231, 139, 245, 243, 251, 235, 201, 196, 240, 214, 152, 182, 205, 181, 127,
|
||||
27, // 0x70 p-DEL
|
||||
212, 211, 210, 213, 228, 197, 169, 159, 131, 172, 105, 80, 98, 96, 97, 81, // 0x80
|
||||
207, 145, 116, 115, 144, 130, 153, 121, 107, 132, 109, 110, 124, 111, 82, 108, // 0x90
|
||||
118, 141, 113, 129, 119, 125, 165, 117, 92, 106, 83, 72, 99, 93, 65, 79, // 0xa0
|
||||
166, 237, 163, 199, 190, 225, 209, 203, 198, 217, 219, 206, 234, 248, 158, 239, // 0xb0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xc0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xd0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xe0
|
||||
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, // 0xf0
|
||||
];
|
||||
|
||||
#[inline]
|
||||
fn ascii_fold_byte(b: u8) -> u8 {
|
||||
if b.is_ascii_uppercase() { b | 0x20 } else { b }
|
||||
}
|
||||
|
||||
/// Toggle ASCII letter case by flipping bit 5.
|
||||
/// `'n' → 'N'`, `'N' → 'n'`.
|
||||
#[inline]
|
||||
fn ascii_swap_case(b: u8) -> u8 {
|
||||
b ^ 0x20
|
||||
}
|
||||
|
||||
/// Effective frequency rank for a case-insensitive byte position.
|
||||
/// Takes the max of lower/upper ranks because we must scan for both.
|
||||
#[inline]
|
||||
fn case_insensitive_rank(lower: u8) -> u8 {
|
||||
if lower.is_ascii_lowercase() {
|
||||
let upper = ascii_swap_case(lower);
|
||||
BYTE_FREQUENCIES[lower as usize].max(BYTE_FREQUENCIES[upper as usize])
|
||||
} else {
|
||||
BYTE_FREQUENCIES[lower as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick two needle positions with the rarest bytes (case-insensitive).
|
||||
/// Returns (index1, index2) where index1 <= index2.
|
||||
fn select_rare_pair(needle_lower: &[u8]) -> (usize, usize) {
|
||||
debug_assert!(needle_lower.len() >= 2);
|
||||
|
||||
let mut best1 = (u8::MAX, 0usize); // (rank, position)
|
||||
let mut best2 = (u8::MAX, 1usize);
|
||||
|
||||
for (i, &b) in needle_lower.iter().enumerate() {
|
||||
let r = case_insensitive_rank(b);
|
||||
if r < best1.0 {
|
||||
best2 = best1;
|
||||
best1 = (r, i);
|
||||
} else if r < best2.0 && i != best1.1 {
|
||||
best2 = (r, i);
|
||||
}
|
||||
}
|
||||
|
||||
let i1 = best1.1.min(best2.1);
|
||||
let i2 = best1.1.max(best2.1);
|
||||
(i1, i2)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn verify_scalar(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
for (i, _) in needle_lower.iter().enumerate() {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// AVX2 case-insensitive verify: checks whether `needle_lower` matches
|
||||
/// the haystack bytes starting at `h`, treating ASCII uppercase as lowercase.
|
||||
///
|
||||
/// Processes 32 bytes at a time using a SIMD trick: AVX2 only has a
|
||||
/// **signed** byte compare (`cmpgt`), but we need an **unsigned** range
|
||||
/// check (`'A' <= byte <= 'Z'`). The trick is to XOR every byte with
|
||||
/// `0x80`, which maps the unsigned range `[0, 255]` into the signed range
|
||||
/// `[-128, 127]` while preserving order. After the flip, signed `cmpgt`
|
||||
/// gives correct unsigned comparisons.
|
||||
///
|
||||
/// Once we know which bytes are uppercase, we set bit 5 (`0x20`) on them
|
||||
/// — this converts `'A'..'Z'` to `'a'..'z'` — then compare against the
|
||||
/// pre-lowered needle.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn verify_avx2(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
use core::arch::x86_64::*;
|
||||
|
||||
let len = needle_lower.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
// Broadcast constants used every iteration:
|
||||
//
|
||||
// flip = 0x80 in every lane — XOR converts unsigned→signed domain
|
||||
// a_minus_1 = ('A' - 1) ^ 0x80 — lower bound for the range check (signed)
|
||||
// z_plus_1 = ('Z' + 1) ^ 0x80 — upper bound for the range check (signed)
|
||||
// bit20 = 0x20 in every lane — OR this onto uppercase bytes to lowercase them
|
||||
let flip = _mm256_set1_epi8(0x80u8 as i8);
|
||||
let a_minus_1 = _mm256_set1_epi8((b'A' - 1) as i8 ^ 0x80u8 as i8);
|
||||
let z_plus_1 = _mm256_set1_epi8((b'Z' + 1) as i8 ^ 0x80u8 as i8);
|
||||
let bit20 = _mm256_set1_epi8(0x20u8 as i8);
|
||||
|
||||
while i + 32 <= len {
|
||||
// Load 32 bytes from the haystack candidate position.
|
||||
let hv = unsafe { _mm256_loadu_si256(h.add(i) as *const __m256i) };
|
||||
// Load 32 bytes from the pre-lowercased needle.
|
||||
let nv = unsafe { _mm256_loadu_si256(needle_lower.as_ptr().add(i) as *const __m256i) };
|
||||
|
||||
// Flip into signed domain: x = hv ^ 0x80.
|
||||
// After this, unsigned ordering is preserved under signed compare.
|
||||
let x = _mm256_xor_si256(hv, flip);
|
||||
|
||||
// ge_a[lane] = 0xFF if x[lane] > a_minus_1, i.e. hv[lane] >= 'A' (unsigned).
|
||||
let ge_a = _mm256_cmpgt_epi8(x, a_minus_1);
|
||||
// le_z[lane] = 0xFF if z_plus_1 > x[lane], i.e. hv[lane] <= 'Z' (unsigned).
|
||||
let le_z = _mm256_cmpgt_epi8(z_plus_1, x);
|
||||
// upper[lane] = 0xFF only for bytes in the range 'A'..='Z'.
|
||||
let upper = _mm256_and_si256(ge_a, le_z);
|
||||
|
||||
// Case-fold: set bit 5 on uppercase bytes → converts 'A'..'Z' to 'a'..'z'.
|
||||
// Non-letter bytes are untouched because their `upper` lane is 0x00.
|
||||
let folded = _mm256_or_si256(hv, _mm256_and_si256(upper, bit20));
|
||||
|
||||
// Compare the folded haystack against the lowercase needle.
|
||||
let eq = _mm256_cmpeq_epi8(folded, nv);
|
||||
// movemask extracts the high bit of each lane into a 32-bit mask.
|
||||
// All-equal → all high bits set → mask == 0xFFFFFFFF == -1i32.
|
||||
if _mm256_movemask_epi8(eq) != -1i32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
i += 32;
|
||||
}
|
||||
|
||||
// Scalar tail: handle remaining bytes that don't fill a full 32-byte vector.
|
||||
while i < len {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ======== NEON + dotprod (aarch64) ===========================================
|
||||
|
||||
/// Extract a 16-bit bitmask from a NEON comparison result (each byte 0x00 or 0xFF).
|
||||
/// Bit *i* of the result corresponds to byte *i* of the input vector.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon")]
|
||||
#[inline]
|
||||
unsafe fn neon_movemask(v: core::arch::aarch64::uint8x16_t) -> u16 {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
// AND each byte with its bit-position mask, then horizontally sum each half.
|
||||
// Max possible sum per half = 1+2+4+8+16+32+64+128 = 255, fits in u8.
|
||||
static BITS: [u8; 16] = [1, 2, 4, 8, 16, 32, 64, 128, 1, 2, 4, 8, 16, 32, 64, 128];
|
||||
let bit_mask = unsafe { vld1q_u8(BITS.as_ptr()) };
|
||||
let masked = vandq_u8(v, bit_mask);
|
||||
let lo = vaddv_u8(vget_low_u8(masked));
|
||||
let hi = vaddv_u8(vget_high_u8(masked));
|
||||
(lo as u16) | ((hi as u16) << 8)
|
||||
}
|
||||
|
||||
/// NEON + dotprod case-insensitive verify.
|
||||
///
|
||||
/// Uses unsigned range checks (NEON has `vcge`/`vcle` for unsigned bytes
|
||||
/// no XOR-0x80 trick needed unlike AVX2) to detect uppercase ASCII, folds
|
||||
/// to lowercase, then checks equality via UDOT: XOR the folded haystack
|
||||
/// with the pre-lowered needle and dot-product the difference with itself.
|
||||
/// Any non-zero byte produces a non-zero u32 lane.
|
||||
///
|
||||
/// The UDOT instruction is emitted via inline asm because the `vdotq_u32`
|
||||
/// intrinsic is still behind an unstable feature gate on stable Rust.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon,dotprod")]
|
||||
unsafe fn verify_neon_dotprod(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
let len = needle_lower.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
let a_val = vdupq_n_u8(b'A');
|
||||
let z_val = vdupq_n_u8(b'Z');
|
||||
let bit20 = vdupq_n_u8(0x20);
|
||||
|
||||
while i + 16 <= len {
|
||||
let hv = unsafe { vld1q_u8(h.add(i)) };
|
||||
let nv = unsafe { vld1q_u8(needle_lower.as_ptr().add(i)) };
|
||||
|
||||
// Unsigned range check: 'A' <= byte <= 'Z'
|
||||
let upper = vandq_u8(vcgeq_u8(hv, a_val), vcleq_u8(hv, z_val));
|
||||
// Case-fold: set bit 5 on uppercase bytes → 'A'..'Z' → 'a'..'z'
|
||||
let folded = vorrq_u8(hv, vandq_u8(upper, bit20));
|
||||
|
||||
// XOR with needle — all-zero iff every byte matches.
|
||||
let xored = veorq_u8(folded, nv);
|
||||
|
||||
// UDOT: dot(xored, xored) sums squares of 4 consecutive byte
|
||||
// differences into each of the 4 u32 lanes (accumulates into zero).
|
||||
// Any non-zero byte produces a positive u32 contribution.
|
||||
let dots: uint32x4_t;
|
||||
let zero = vdupq_n_u32(0);
|
||||
unsafe {
|
||||
core::arch::asm!(
|
||||
"udot {d:v}.4s, {a:v}.16b, {b:v}.16b",
|
||||
d = inlateout(vreg) zero => dots,
|
||||
a = in(vreg) xored,
|
||||
b = in(vreg) xored,
|
||||
);
|
||||
}
|
||||
|
||||
if vmaxvq_u32(dots) != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
i += 16;
|
||||
}
|
||||
|
||||
// Scalar tail
|
||||
while i < len {
|
||||
if ascii_fold_byte(unsafe { *h.add(i) }) != needle_lower[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// NEON packed-pair kernel: scan 16 haystack positions per iteration,
|
||||
/// checking two rare bytes (case-insensitive) simultaneously.
|
||||
/// Same algorithm as the AVX2 version but with 128-bit vectors.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[target_feature(enable = "neon")]
|
||||
unsafe fn search_packed_pair_neon(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> bool {
|
||||
use core::arch::aarch64::*;
|
||||
|
||||
let n = needle_lower.len();
|
||||
let hlen = haystack.len();
|
||||
let ptr = haystack.as_ptr();
|
||||
let last_start = hlen - n;
|
||||
|
||||
let b1 = needle_lower[i1];
|
||||
let b1_alt = if b1.is_ascii_lowercase() {
|
||||
ascii_swap_case(b1)
|
||||
} else {
|
||||
b1
|
||||
};
|
||||
let b2 = needle_lower[i2];
|
||||
let b2_alt = if b2.is_ascii_lowercase() {
|
||||
ascii_swap_case(b2)
|
||||
} else {
|
||||
b2
|
||||
};
|
||||
|
||||
let v1_lo = vdupq_n_u8(b1);
|
||||
let v1_hi = vdupq_n_u8(b1_alt);
|
||||
let v2_lo = vdupq_n_u8(b2);
|
||||
let v2_hi = vdupq_n_u8(b2_alt);
|
||||
|
||||
let max_idx = i1.max(i2);
|
||||
let max_offset = hlen.saturating_sub(max_idx + 16);
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset <= max_offset {
|
||||
let chunk1 = unsafe { vld1q_u8(ptr.add(offset + i1)) };
|
||||
let chunk2 = unsafe { vld1q_u8(ptr.add(offset + i2)) };
|
||||
|
||||
// Case-insensitive match: OR both case variants, then AND the two positions.
|
||||
let eq1 = vorrq_u8(vceqq_u8(chunk1, v1_lo), vceqq_u8(chunk1, v1_hi));
|
||||
let eq2 = vorrq_u8(vceqq_u8(chunk2, v2_lo), vceqq_u8(chunk2, v2_hi));
|
||||
|
||||
let mut mask = unsafe { neon_movemask(vandq_u8(eq1, eq2)) };
|
||||
|
||||
while mask != 0 {
|
||||
let bit = mask.trailing_zeros() as usize;
|
||||
let candidate = offset + bit;
|
||||
if candidate > last_start {
|
||||
return false;
|
||||
}
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 16;
|
||||
}
|
||||
|
||||
// Tail: remaining positions that couldn't fill a full vector.
|
||||
if offset <= last_start {
|
||||
let rare_pos =
|
||||
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
|
||||
i1
|
||||
} else {
|
||||
i2
|
||||
};
|
||||
let rare_byte = needle_lower[rare_pos];
|
||||
let tail_start = offset + rare_pos;
|
||||
let tail_end = last_start + rare_pos + 1;
|
||||
if tail_start < tail_end {
|
||||
let tail_space = &haystack[tail_start..tail_end];
|
||||
if rare_byte.is_ascii_lowercase() {
|
||||
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[inline]
|
||||
unsafe fn verify_dispatch(h: *const u8, needle_lower: &[u8]) -> bool {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if needle_lower.len() >= 32 && std::is_x86_feature_detected!("avx2") {
|
||||
return unsafe { verify_avx2(h, needle_lower) };
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
if needle_lower.len() >= 16 && std::arch::is_aarch64_feature_detected!("dotprod") {
|
||||
return unsafe { verify_neon_dotprod(h, needle_lower) };
|
||||
}
|
||||
}
|
||||
|
||||
verify_scalar(h, needle_lower)
|
||||
}
|
||||
|
||||
// ── Packed-pair search (AVX2) ───────────────────────────────────────────
|
||||
|
||||
/// AVX2 packed-pair kernel: scan 32 haystack positions per iteration,
|
||||
/// checking two rare bytes (case-insensitive) simultaneously.
|
||||
/// 4 cmpeq + 2 or + 1 and + 1 movemask per 32 bytes — same memory
|
||||
/// bandwidth as memchr2 but quadratic selectivity.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2")]
|
||||
unsafe fn search_packed_pair_avx2(
|
||||
haystack: &[u8],
|
||||
needle_lower: &[u8],
|
||||
i1: usize,
|
||||
i2: usize,
|
||||
) -> bool {
|
||||
use core::arch::x86_64::*;
|
||||
|
||||
let n = needle_lower.len();
|
||||
let hlen = haystack.len();
|
||||
let ptr = haystack.as_ptr();
|
||||
let last_start = hlen - n; // last valid match-start position
|
||||
|
||||
let b1 = needle_lower[i1];
|
||||
let b1_alt = if b1.is_ascii_lowercase() {
|
||||
ascii_swap_case(b1)
|
||||
} else {
|
||||
b1
|
||||
};
|
||||
let b2 = needle_lower[i2];
|
||||
let b2_alt = if b2.is_ascii_lowercase() {
|
||||
ascii_swap_case(b2)
|
||||
} else {
|
||||
b2
|
||||
};
|
||||
|
||||
let v1_lo = _mm256_set1_epi8(b1 as i8);
|
||||
let v1_hi = _mm256_set1_epi8(b1_alt as i8);
|
||||
let v2_lo = _mm256_set1_epi8(b2 as i8);
|
||||
let v2_hi = _mm256_set1_epi8(b2_alt as i8);
|
||||
|
||||
// Main loop: process 32 candidate positions per iteration.
|
||||
// We load from ptr+offset+i1 and ptr+offset+i2, so we need
|
||||
// offset + max(i1,i2) + 31 < hlen.
|
||||
let max_idx = i1.max(i2);
|
||||
let max_offset = hlen.saturating_sub(max_idx + 32);
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset <= max_offset {
|
||||
let chunk1 = unsafe { _mm256_loadu_si256(ptr.add(offset + i1) as *const __m256i) };
|
||||
let chunk2 = unsafe { _mm256_loadu_si256(ptr.add(offset + i2) as *const __m256i) };
|
||||
|
||||
// Case-insensitive match: OR both case variants, then AND the two positions.
|
||||
let eq1 = _mm256_or_si256(
|
||||
_mm256_cmpeq_epi8(chunk1, v1_lo),
|
||||
_mm256_cmpeq_epi8(chunk1, v1_hi),
|
||||
);
|
||||
let eq2 = _mm256_or_si256(
|
||||
_mm256_cmpeq_epi8(chunk2, v2_lo),
|
||||
_mm256_cmpeq_epi8(chunk2, v2_hi),
|
||||
);
|
||||
|
||||
let mut mask = _mm256_movemask_epi8(_mm256_and_si256(eq1, eq2)) as u32;
|
||||
|
||||
while mask != 0 {
|
||||
let bit = mask.trailing_zeros() as usize;
|
||||
let candidate = offset + bit;
|
||||
if candidate > last_start {
|
||||
// Past the end — no more valid positions in this or future chunks.
|
||||
return false;
|
||||
}
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
mask &= mask - 1;
|
||||
}
|
||||
|
||||
offset += 32;
|
||||
}
|
||||
|
||||
// Tail: remaining positions that couldn't fill a full vector.
|
||||
// Use memchr2 on the rarest byte for these last few positions.
|
||||
if offset <= last_start {
|
||||
let rare_pos =
|
||||
if case_insensitive_rank(needle_lower[i1]) <= case_insensitive_rank(needle_lower[i2]) {
|
||||
i1
|
||||
} else {
|
||||
i2
|
||||
};
|
||||
let rare_byte = needle_lower[rare_pos];
|
||||
let tail_start = offset + rare_pos;
|
||||
let tail_end = last_start + rare_pos + 1;
|
||||
if tail_start < tail_end {
|
||||
let tail_space = &haystack[tail_start..tail_end];
|
||||
if rare_byte.is_ascii_lowercase() {
|
||||
for pos in memchr::memchr2_iter(rare_byte, ascii_swap_case(rare_byte), tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(rare_byte, tail_space) {
|
||||
let candidate = offset + pos;
|
||||
if unsafe { verify_dispatch(ptr.add(candidate), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Packed-pair case-insensitive substring search.
|
||||
///
|
||||
/// Selects the two rarest bytes from the needle (using the memchr byte
|
||||
/// frequency heuristic), then SIMD-scans for both at their known offsets
|
||||
/// simultaneously. Falls back to `search` for needles shorter than 2 bytes.
|
||||
pub fn search_packed_pair(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
if n < 2 {
|
||||
return search(haystack, needle_lower);
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (i1, i2) = select_rare_pair(needle_lower);
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if std::is_x86_feature_detected!("avx2") {
|
||||
// Need enough haystack for at least one vector load.
|
||||
let max_idx = i1.max(i2);
|
||||
if haystack.len() >= max_idx + 32 {
|
||||
return unsafe { search_packed_pair_avx2(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
// The NEON packed-pair scan checks 16 bytes/iteration with ~7 ops,
|
||||
// while memchr's optimized loop processes more bytes with fewer ops.
|
||||
// Packed-pair wins when the first byte is common (lots of false
|
||||
// positives for memchr2 that we avoid). But when the first byte is
|
||||
// rare (z, q, x, ...) memchr2 has no false positives and its raw
|
||||
// throughput dominates. Threshold 200 on the frequency table splits
|
||||
// common letters (s=243, e=253, f=227) from rare ones (z=152, q=139).
|
||||
let first_byte_rank = case_insensitive_rank(needle_lower[0]);
|
||||
let max_idx = i1.max(i2);
|
||||
if first_byte_rank >= 200 && haystack.len() >= max_idx + 16 {
|
||||
return unsafe { search_packed_pair_neon(haystack, needle_lower, i1, i2) };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for short haystacks or non-SIMD platforms.
|
||||
search(haystack, needle_lower)
|
||||
}
|
||||
|
||||
// ── Original memchr2 first-byte search ──────────────────────────────────
|
||||
|
||||
/// Case-insensitive search using memchr2 on the first byte.
|
||||
pub fn search(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let search_space = &haystack[..=haystack.len() - n];
|
||||
let first = needle_lower[0];
|
||||
|
||||
if first.is_ascii_lowercase() {
|
||||
let alt = ascii_swap_case(first);
|
||||
for pos in memchr::memchr2_iter(first, alt, search_space) {
|
||||
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(first, search_space) {
|
||||
if unsafe { verify_dispatch(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_case_insensitive() {
|
||||
assert!(search_packed_pair(b"Hello World", b"hello"));
|
||||
assert!(search_packed_pair(b"Hello World", b"world"));
|
||||
assert!(search_packed_pair(b"NOMORE bugs", b"nomore"));
|
||||
assert!(!search_packed_pair(b"Hello World", b"xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases() {
|
||||
assert!(search_packed_pair(b"ab", b"ab"));
|
||||
assert!(search_packed_pair(b"AB", b"ab"));
|
||||
assert!(!search_packed_pair(b"a", b"ab"));
|
||||
assert!(search_packed_pair(b"anything", b""));
|
||||
assert!(!search_packed_pair(b"", b"x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_pair_matches_search() {
|
||||
let haystacks: &[&[u8]] = &[
|
||||
b"The quick brown fox jumps over the lazy dog",
|
||||
b"int mutex_lock(struct mutex *lock) { return 0; }",
|
||||
b"#define NOMORE_RETRIES 5\nif (nomore) return;",
|
||||
b"abcdefghijklmnopqrstuvwxyz",
|
||||
b"short",
|
||||
];
|
||||
let needles: &[&[u8]] = &[b"fox", b"mutex", b"nomore", b"xyz", b"the", b"short", b"qr"];
|
||||
for h in haystacks {
|
||||
for n in needles {
|
||||
let lower: Vec<u8> = n.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
assert_eq!(
|
||||
search_packed_pair(h, &lower),
|
||||
search(h, &lower),
|
||||
"mismatch for haystack={:?} needle={:?}",
|
||||
std::str::from_utf8(h),
|
||||
std::str::from_utf8(n),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_haystack_neon_path() {
|
||||
// Haystack > 16 bytes exercises NEON packed-pair search loop
|
||||
let haystack =
|
||||
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaTHIS_IS_A_LONG_NEEDLE_TESTbbbbbbbbbbbbbbbbbb";
|
||||
assert!(search_packed_pair(haystack, b"this_is_a_long_needle_test"));
|
||||
assert!(!search_packed_pair(
|
||||
haystack,
|
||||
b"this_is_a_long_needle_testz"
|
||||
));
|
||||
|
||||
// Needle >= 16 bytes exercises NEON dotprod verify
|
||||
let long_needle = b"struct mutex *lock";
|
||||
let haystack2 = b"int STRUCT MUTEX *LOCK(struct mutex *lock) { return 0; }";
|
||||
assert!(search_packed_pair(haystack2, long_needle));
|
||||
|
||||
// All uppercase haystack, lowercase needle
|
||||
let upper_hay = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
assert!(search_packed_pair(upper_hay, b"qrstuvwxyz0123456789a"));
|
||||
assert!(!search_packed_pair(upper_hay, b"qrstuvwxyz01234567899"));
|
||||
|
||||
// Needle at very end
|
||||
let end_hay = b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfind_me";
|
||||
assert!(search_packed_pair(end_hay, b"find_me"));
|
||||
|
||||
// Needle at very start
|
||||
assert!(search_packed_pair(end_hay, b"xx"));
|
||||
|
||||
// 1KB haystack with needle near the end
|
||||
let mut big = vec![b'z'; 1024];
|
||||
big[1000..1010].copy_from_slice(b"hElLo_WoRl");
|
||||
assert!(search_packed_pair(&big, b"hello_wo"));
|
||||
assert!(!search_packed_pair(&big, b"hello_world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rare_pair_selection() {
|
||||
// For "nomore": n=246, o=244, m=233, o=244, r=245, e=253
|
||||
// Rarest positions should include 'm' (pos 2, rank 233)
|
||||
let (i1, i2) = select_rare_pair(b"nomore");
|
||||
let ranks: Vec<u8> = b"nomore"
|
||||
.iter()
|
||||
.map(|&b| case_insensitive_rank(b))
|
||||
.collect();
|
||||
let r1 = ranks[i1];
|
||||
let r2 = ranks[i2];
|
||||
// Both selected ranks should be <= all other ranks
|
||||
for (i, &r) in ranks.iter().enumerate() {
|
||||
if i != i1 && i != i2 {
|
||||
assert!(r1 <= r || r2 <= r, "pair ({i1},{i2}) not optimal");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,32 @@ use smallvec::SmallVec;
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
|
||||
/// Case-insensitive ASCII substring search without allocation.
|
||||
/// `needle` must already be lowercase.
|
||||
#[inline]
|
||||
fn contains_ascii_ci(haystack: &str, needle: &str) -> bool {
|
||||
let h = haystack.as_bytes();
|
||||
let n = needle.as_bytes();
|
||||
if n.len() > h.len() {
|
||||
return false;
|
||||
}
|
||||
if n.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let first = n[0];
|
||||
for i in 0..=(h.len() - n.len()) {
|
||||
if h[i].to_ascii_lowercase() == first
|
||||
&& h[i..i + n.len()]
|
||||
.iter()
|
||||
.zip(n)
|
||||
.all(|(a, b)| a.to_ascii_lowercase() == *b)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Minimum item count before switching to parallel iteration with rayon.
|
||||
/// Below this threshold, the overhead of thread pool dispatch outweighs the benefit.
|
||||
const PAR_THRESHOLD: usize = 10_000;
|
||||
@@ -22,9 +48,6 @@ pub trait Constrainable {
|
||||
/// The file's relative path (e.g. "src/main.rs")
|
||||
fn relative_path(&self) -> &str;
|
||||
|
||||
/// The file's lowercased relative path for case-insensitive matching
|
||||
fn relative_path_lower(&self) -> &str;
|
||||
|
||||
/// The file name component (e.g. "main.rs")
|
||||
fn file_name(&self) -> &str;
|
||||
|
||||
@@ -43,26 +66,29 @@ pub trait Constrainable {
|
||||
/// - `path_ends_with_suffix("xlibswscale/input.c", "libswscale/input.c")` → false (no boundary)
|
||||
#[inline]
|
||||
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
|
||||
if path.len() < suffix.len() {
|
||||
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) {
|
||||
let start = path_bytes.len() - suffix_bytes.len();
|
||||
if !path_bytes[start..].eq_ignore_ascii_case(suffix_bytes) {
|
||||
return false;
|
||||
}
|
||||
// Exact match, or the character before is /
|
||||
start == 0 || path.as_bytes()[start - 1] == b'/'
|
||||
start == 0 || path_bytes[start - 1] == b'/'
|
||||
}
|
||||
|
||||
/// 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;
|
||||
name_bytes.get(start) == Some(&b'.') && name_bytes[start + 1..].eq_ignore_ascii_case(ext_bytes)
|
||||
}
|
||||
|
||||
/// Check if path contains segment (without allocation)
|
||||
@@ -71,28 +97,29 @@ pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
|
||||
#[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
|
||||
if path_bytes.len() > segment_len
|
||||
&& path_bytes.get(segment_len) == Some(&b'/')
|
||||
&& path[..segment_len].eq_ignore_ascii_case(segment)
|
||||
&& path_bytes[..segment_len].eq_ignore_ascii_case(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) {
|
||||
for i in 0..path_bytes.len().saturating_sub(segment_len + 1) {
|
||||
if path_bytes[i] == b'/' {
|
||||
let start = i + 1;
|
||||
let end = start + segment_len;
|
||||
if end < path.len()
|
||||
if end < path_bytes.len()
|
||||
&& path_bytes[end] == b'/'
|
||||
&& path[start..end].eq_ignore_ascii_case(segment)
|
||||
&& path_bytes[start..end].eq_ignore_ascii_case(segment_bytes)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -152,7 +179,7 @@ fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
}
|
||||
|
||||
// only works with negation
|
||||
Constraint::Text(text) => item.relative_path_lower().contains(text),
|
||||
Constraint::Text(text) => contains_ascii_ci(item.relative_path(), text),
|
||||
|
||||
// Parts and Exclude are handled at a higher level
|
||||
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
|
||||
@@ -446,4 +473,33 @@ 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"));
|
||||
}
|
||||
|
||||
#[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_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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ pub enum Error {
|
||||
ThreadPanic,
|
||||
#[error("Invalid path {0}")]
|
||||
InvalidPath(std::path::PathBuf),
|
||||
#[error(
|
||||
"Can not start fff at the file system root {0} — pass a project or at least home directory instead"
|
||||
)]
|
||||
FilesystemRoot(std::path::PathBuf),
|
||||
#[error("File picker not initialized")]
|
||||
FilePickerMissing,
|
||||
#[error("Failed to acquire lock for frecency")]
|
||||
@@ -17,10 +21,17 @@ 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}")]
|
||||
DbCreate(#[source] heed::Error),
|
||||
#[error("Failed to open frecency database: {0}")]
|
||||
DbOpen(#[source] heed::Error),
|
||||
#[error("Failed to clear stale readers for frecency database: {0}")]
|
||||
DbClearStaleReaders(#[source] heed::Error),
|
||||
|
||||
|
||||
+1160
-406
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::file_picker::FFFMode;
|
||||
use crate::{SharedFrecency, git::is_modified_status};
|
||||
use crate::git::is_modified_status;
|
||||
use crate::shared::SharedFrecency;
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{
|
||||
EnvFlags,
|
||||
@@ -57,6 +58,11 @@ impl DbHealthChecker for FrecencyTracker {
|
||||
}
|
||||
|
||||
impl FrecencyTracker {
|
||||
/// Returns the on-disk path of the LMDB environment directory.
|
||||
pub fn db_path(&self) -> &Path {
|
||||
self.env.path()
|
||||
}
|
||||
|
||||
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self> {
|
||||
let db_path = db_path.as_ref();
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
@@ -72,11 +78,28 @@ impl FrecencyTracker {
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
|
||||
// we will open the default unnamed database
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, None)
|
||||
.map_err(Error::DbCreate)?;
|
||||
// Try read-only open first — avoids blocking on the LMDB write lock
|
||||
// when another process (Neovim, another fff-mcp) already has it.
|
||||
// Only fall back to create_database (which needs a write txn) if the
|
||||
// database doesn't exist yet.
|
||||
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let maybe_db: Option<Database<Bytes, SerdeBincode<VecDeque<u64>>>> =
|
||||
env.open_database(&rtxn, None).map_err(Error::DbOpen)?;
|
||||
|
||||
drop(rtxn);
|
||||
|
||||
let db = match maybe_db {
|
||||
Some(db) => db,
|
||||
None => {
|
||||
// First time: create the database (requires write lock).
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, None)
|
||||
.map_err(Error::DbCreate)?;
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
db
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FrecencyTracker {
|
||||
db,
|
||||
|
||||
+605
-188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const NON_GIT_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
".venv",
|
||||
// Rust (these are glob-only patterns for non_git_repo_overrides,
|
||||
// is_non_code_directory matches the "target" component separately)
|
||||
"target/debug",
|
||||
"target/release",
|
||||
"target/rust-analyzer",
|
||||
"target/criterion",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] =
|
||||
&["Library/Application Support", "Library/Caches"];
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[
|
||||
"bin/Debug",
|
||||
"bin/Release",
|
||||
"Program Files",
|
||||
"Program Files (x86)",
|
||||
"AppData/Local",
|
||||
"AppData/Roaming",
|
||||
];
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
pub(crate) const PLATFORM_IGNORED_DIRS: &[&str] = &[];
|
||||
|
||||
pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option<ignore::overrides::Override> {
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
|
||||
let mut builder = OverrideBuilder::new(base_path);
|
||||
for dir in NON_GIT_IGNORED_DIRS.iter().chain(PLATFORM_IGNORED_DIRS) {
|
||||
let pattern = format!("!**/{dir}/");
|
||||
if let Err(e) = builder.add(&pattern) {
|
||||
tracing::warn!("failed to add ignore pattern {pattern}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
builder.build().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn is_non_code_directory(path: &Path) -> bool {
|
||||
let path_str = path.as_os_str().to_str().unwrap_or("");
|
||||
NON_GIT_IGNORED_DIRS
|
||||
.iter()
|
||||
.chain(PLATFORM_IGNORED_DIRS)
|
||||
.any(|&dir| {
|
||||
#[cfg(target_os = "windows")]
|
||||
let dir = dir.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
#[cfg(target_os = "windows")]
|
||||
return path_str.contains(dir.as_str());
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
path_str.contains(dir)
|
||||
})
|
||||
}
|
||||
+32
-31
@@ -21,10 +21,9 @@
|
||||
//! ## Shared State
|
||||
//!
|
||||
//! [`SharedPicker`], [`SharedFrecency`], and [`SharedQueryTracker`] are
|
||||
//! `Arc<RwLock<Option<T>>>` type aliases for thread-safe shared access. FFF
|
||||
//! is designed for long-running processes that keep the file index in global
|
||||
//! state, so these wrappers let background threads (scanner, watcher) share
|
||||
//! data with the calling code safely.
|
||||
//! newtype wrappers around `Arc<RwLock<Option<T>>>` for thread-safe shared
|
||||
//! access. They provide `read()` / `write()` methods with built-in error
|
||||
//! conversion and convenience helpers like `wait_for_scan()`.
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
@@ -33,49 +32,51 @@
|
||||
//! use fff_search::frecency::FrecencyTracker;
|
||||
//! use fff_search::query_tracker::QueryTracker;
|
||||
//! use fff_search::{
|
||||
//! FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser,
|
||||
//! FFFMode, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
|
||||
//! SharedFrecency, SharedPicker, SharedQueryTracker,
|
||||
//! };
|
||||
//!
|
||||
//! let shared_picker: SharedPicker = Default::default();
|
||||
//! let shared_frecency: SharedFrecency = Default::default();
|
||||
//! let shared_query_tracker: SharedQueryTracker = Default::default();
|
||||
//! let shared_picker = SharedPicker::default();
|
||||
//! let shared_frecency = SharedFrecency::default();
|
||||
//! let shared_query_tracker = SharedQueryTracker::default();
|
||||
//!
|
||||
//! let tmp = std::env::temp_dir().join("fff-doctest");
|
||||
//! std::fs::create_dir_all(&tmp).unwrap();
|
||||
//!
|
||||
//! // 1. Optionally initialize frecency and query tracker databases
|
||||
//! let frecency = FrecencyTracker::new(tmp.join("frecency"), false)?;
|
||||
//! *shared_frecency.write().unwrap() = Some(frecency);
|
||||
//! shared_frecency.init(frecency)?;
|
||||
//!
|
||||
//! let query_tracker = QueryTracker::new(tmp.join("queries"), false)?;
|
||||
//! *shared_query_tracker.write().unwrap() = Some(query_tracker);
|
||||
//! shared_query_tracker.init(query_tracker)?;
|
||||
//!
|
||||
//! // 2. Init the file picker (spawns background scan + watcher)
|
||||
//! FilePicker::new_with_shared_state(
|
||||
//! ".".into(),
|
||||
//! /* warmup memap caches = */ false,
|
||||
//! FFFMode::Ai, // use AI for ai agents, and Neovim for editors
|
||||
//! shared_picker.clone(),
|
||||
//! shared_frecency.clone(),
|
||||
//! FilePickerOptions {
|
||||
//! base_path: ".".into(),
|
||||
//! mode: FFFMode::Ai,
|
||||
//! ..Default::default()
|
||||
//! },
|
||||
//! )?;
|
||||
//!
|
||||
//! // 3. Wait for scan (in real app you would like to add some tokio flavor here)
|
||||
//! FilePicker::wait_for_scan(&shared_picker);
|
||||
//! // 3. Wait for scan
|
||||
//! shared_picker.wait_for_scan(std::time::Duration::from_secs(10));
|
||||
//!
|
||||
//! // 4. Search: lock the picker and query tracker
|
||||
//! let picker_lock_guard = shared_picker.read().unwrap();
|
||||
//! let picker = picker_lock_guard.as_ref().unwrap();
|
||||
//! let query_tracker_lock_guard = shared_query_tracker.read().unwrap();
|
||||
//! let picker_guard = shared_picker.read()?;
|
||||
//! let picker = picker_guard.as_ref().unwrap();
|
||||
//! let qt_guard = shared_query_tracker.read()?;
|
||||
//!
|
||||
//! // 5. Parse the query and perform fuzzy search with frecency and combo-boost scoring
|
||||
//! // 5. Parse the query and perform fuzzy search
|
||||
//! let parser = QueryParser::default();
|
||||
//! let query = parser.parse("lib.rs");
|
||||
//!
|
||||
//! let results = FilePicker::fuzzy_search(
|
||||
//! picker.get_files(),
|
||||
//! &query,
|
||||
//! query_tracker_lock_guard.as_ref(),
|
||||
//! qt_guard.as_ref(),
|
||||
//! FuzzySearchOptions {
|
||||
//! max_threads: 0,
|
||||
//! current_file: None,
|
||||
@@ -85,18 +86,22 @@
|
||||
//! );
|
||||
//!
|
||||
//! assert!(results.total_matched > 0);
|
||||
//! assert!(results.items.first().unwrap().path.ends_with("lib.rs"));
|
||||
//! assert!(results.items.first().unwrap().as_path().ends_with("lib.rs"));
|
||||
//!
|
||||
//! let _ = std::fs::remove_dir_all(&tmp);
|
||||
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
|
||||
mod background_watcher;
|
||||
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;
|
||||
|
||||
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
|
||||
///
|
||||
@@ -133,17 +138,12 @@ pub mod query_tracker;
|
||||
/// Core data types shared across the crate.
|
||||
pub mod types;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Thread-safe shared handle to the [`FilePicker`] instance.
|
||||
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
|
||||
|
||||
/// Thread-safe shared handle to the [`QueryTracker`] instance.
|
||||
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
|
||||
mod ignore;
|
||||
/// Thread-safe shared handles for [`FilePicker`], [`FrecencyTracker`],
|
||||
/// and [`QueryTracker`].
|
||||
pub mod shared;
|
||||
|
||||
pub use bigram_filter::*;
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use fff_query_parser::*;
|
||||
@@ -151,4 +151,5 @@ pub use file_picker::*;
|
||||
pub use frecency::*;
|
||||
pub use grep::*;
|
||||
pub use query_tracker::*;
|
||||
pub use shared::*;
|
||||
pub use types::*;
|
||||
|
||||
+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),
|
||||
)
|
||||
|
||||
@@ -61,6 +61,11 @@ impl DbHealthChecker for QueryTracker {
|
||||
}
|
||||
|
||||
impl QueryTracker {
|
||||
/// Returns the on-disk path of the LMDB environment directory.
|
||||
pub fn db_path(&self) -> &Path {
|
||||
self.env.path()
|
||||
}
|
||||
|
||||
pub fn new(db_path: impl AsRef<Path>, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
let db_path = db_path.as_ref();
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
@@ -246,7 +251,6 @@ impl QueryTracker {
|
||||
min_combo_count: u32,
|
||||
) -> Result<Option<QueryMatchEntry>, Error> {
|
||||
let query_key = Self::create_query_key(project_path, query)?;
|
||||
tracing::debug!(?query_key, "HASH");
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let last_match = self
|
||||
|
||||
+344
-172
@@ -34,24 +34,6 @@ impl<'a> FileItems<'a> {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get(&self, index: usize) -> Option<&'a FileItem> {
|
||||
match self {
|
||||
FileItems::All(s) => s.get(index),
|
||||
FileItems::Filtered(v) => v.get(index).copied(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the haystack of relative paths (original casing) for fuzzy matching.
|
||||
/// neo_frizbee lowercases internally for comparison but preserves original casing
|
||||
/// for capitalization_bonus and matching_case_bonus scoring.
|
||||
fn relative_paths(&self) -> Vec<&'a str> {
|
||||
match self {
|
||||
FileItems::All(s) => s.iter().map(|f| f.relative_path.as_str()).collect(),
|
||||
FileItems::Filtered(v) => v.iter().map(|f| f.relative_path.as_str()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index into the file list. Panics if out of bounds (like slice indexing).
|
||||
#[inline]
|
||||
fn index(&self, index: usize) -> &'a FileItem {
|
||||
@@ -66,17 +48,17 @@ impl<'a> FileItems<'a> {
|
||||
/// Single part: use optimized batch matching.
|
||||
/// Multiple parts: each part must match, scores are summed (Nucleo-style).
|
||||
/// Parts with less than 2 characters are skipped.
|
||||
///
|
||||
/// Files are passed directly to frizbee via the `Matchable` trait —
|
||||
/// deleted files return `None` from `match_str()` and are skipped
|
||||
/// without any intermediate allocation.
|
||||
#[inline]
|
||||
fn match_fuzzy_parts(
|
||||
fuzzy_parts: &[&str],
|
||||
working_files: &FileItems<'_>,
|
||||
options: &neo_frizbee::Config,
|
||||
max_threads: usize,
|
||||
) -> Vec<neo_frizbee::Match> {
|
||||
if fuzzy_parts.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let haystack: Vec<&str> = working_files.relative_paths();
|
||||
|
||||
// Filter out parts that are too short (< 2 chars)
|
||||
let valid_parts: Vec<&str> = fuzzy_parts
|
||||
.iter()
|
||||
@@ -89,14 +71,21 @@ fn match_fuzzy_parts(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let first_part_matches = match working_files {
|
||||
FileItems::All(files) => {
|
||||
neo_frizbee::match_list_parallel(valid_parts[0], files, options, max_threads)
|
||||
}
|
||||
FileItems::Filtered(files) => {
|
||||
neo_frizbee::match_list_parallel(valid_parts[0], files, options, max_threads)
|
||||
}
|
||||
};
|
||||
|
||||
if valid_parts.len() == 1 {
|
||||
let matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
|
||||
return matches;
|
||||
return first_part_matches;
|
||||
}
|
||||
|
||||
// Multiple parts - match first part, then filter by remaining parts
|
||||
// TODO figure out if we can move this logic to my frizbee fork at least
|
||||
let mut matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
|
||||
let mut matches = first_part_matches;
|
||||
for part in valid_parts[1..].iter() {
|
||||
let mut part_options = *options;
|
||||
part_options.max_typos = options.max_typos.map(|t| t.min(part.len() as u16));
|
||||
@@ -104,8 +93,9 @@ fn match_fuzzy_parts(
|
||||
matches = matches
|
||||
.into_iter()
|
||||
.filter_map(|mut m| {
|
||||
let path = haystack.get(m.index as usize)?;
|
||||
let part_matches = neo_frizbee::match_list(part, &[*path], &part_options);
|
||||
let file = working_files.index(m.index as usize);
|
||||
let path = file.relative_path();
|
||||
let part_matches = neo_frizbee::match_list(part, &[path], &part_options);
|
||||
let part_match = part_matches.first()?;
|
||||
|
||||
// Sum scores
|
||||
@@ -144,22 +134,14 @@ pub fn match_and_score_files<'a>(
|
||||
}
|
||||
};
|
||||
|
||||
let query_trimmed: &str = parsed.raw_query.trim();
|
||||
let single_part_storage: [&str; 1] = [query_trimmed];
|
||||
|
||||
let fuzzy_parts: &[&str] = match &parsed.fuzzy_query {
|
||||
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
|
||||
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
|
||||
FuzzyQuery::Text(_) | FuzzyQuery::Parts(_) => {
|
||||
_ => {
|
||||
return score_filtered_by_frecency(&working_files, context);
|
||||
}
|
||||
FuzzyQuery::Empty => {
|
||||
if query_trimmed.len() < 2 {
|
||||
return score_filtered_by_frecency(&working_files, context);
|
||||
}
|
||||
&single_part_storage
|
||||
}
|
||||
};
|
||||
debug_assert!(!fuzzy_parts.is_empty());
|
||||
|
||||
let has_uppercase = fuzzy_parts
|
||||
.iter()
|
||||
@@ -176,54 +158,62 @@ pub fn match_and_score_files<'a>(
|
||||
},
|
||||
};
|
||||
|
||||
let path_matches = match_fuzzy_parts(fuzzy_parts, &working_files, &options);
|
||||
let primary_text = fuzzy_parts[0]; // Use first part for filename matching
|
||||
let haystack_of_filenames: Vec<&str> = path_matches
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
working_files
|
||||
.get(m.index as usize)
|
||||
.map(|f| f.file_name.as_str())
|
||||
})
|
||||
.collect();
|
||||
let path_matches =
|
||||
match_fuzzy_parts(fuzzy_parts, &working_files, &options, context.max_threads);
|
||||
|
||||
// if there is a / in the query we don't even match filenames
|
||||
let filename_matches = if query_contains_path_separator {
|
||||
let main_needle = fuzzy_parts[0].as_bytes(); // safe
|
||||
let main_needle_len = main_needle.len() as u16;
|
||||
|
||||
// Filename match detection: two tiers, cursor-based (no intermediate bitset/Vec<bool>).
|
||||
// 1) Collect filenames only where match_end_col didn't land in the filename region.
|
||||
// 2) Batch SIMD on that subset, remap indices, sort for cursor walk in the scoring loop.
|
||||
let mut fallback_indices: Vec<u32> = Vec::new();
|
||||
let filename_fallback_matches = if query_contains_path_separator || path_matches.len() > 15_000
|
||||
{
|
||||
vec![]
|
||||
} else {
|
||||
// Use parallel matching only if we have enough filenames to justify overhead
|
||||
// Sequential matching is faster for small result sets (< 1000 matches)
|
||||
let mut list = if haystack_of_filenames.len() > 1000 {
|
||||
neo_frizbee::match_list_parallel(
|
||||
primary_text,
|
||||
&haystack_of_filenames,
|
||||
&options,
|
||||
context.max_threads,
|
||||
)
|
||||
} else {
|
||||
neo_frizbee::match_list(primary_text, &haystack_of_filenames, &options)
|
||||
};
|
||||
let mut fallback_filenames: Vec<&str> = Vec::new();
|
||||
|
||||
// Sequential sort is faster for small lists
|
||||
if list.len() > 1000 {
|
||||
list.par_sort_unstable_by_key(|m| m.index);
|
||||
} else {
|
||||
sort_by_key_with_buffer(&mut list, |m| m.index);
|
||||
for (i, path_match) in path_matches.iter().enumerate() {
|
||||
let file = working_files.index(path_match.index as usize);
|
||||
let filename_start = file.filename_offset_in_relative() as u16;
|
||||
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
|
||||
|
||||
if match_start_approx < filename_start {
|
||||
fallback_indices.push(i as u32);
|
||||
fallback_filenames.push(file.file_name());
|
||||
}
|
||||
}
|
||||
|
||||
list
|
||||
if fallback_filenames.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
let mut matches = neo_frizbee::match_list_parallel(
|
||||
fuzzy_parts[0],
|
||||
&fallback_filenames,
|
||||
&options,
|
||||
if path_matches.len() > 10_000 {
|
||||
context.max_threads
|
||||
} else {
|
||||
1
|
||||
},
|
||||
);
|
||||
|
||||
sort_by_key_with_buffer(&mut matches, |m| fallback_indices[m.index as usize]);
|
||||
matches
|
||||
}
|
||||
};
|
||||
|
||||
let mut next_filename_match_index = 0;
|
||||
let mut next_filename_match_cursor = 0;
|
||||
let results: Vec<_> = path_matches
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, path_match)| {
|
||||
.map(|(match_idx, path_match)| {
|
||||
let file_idx = path_match.index as usize;
|
||||
let file = working_files.index(file_idx);
|
||||
|
||||
let mut base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
|
||||
let base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score()) / 100;
|
||||
|
||||
// Give modified/dirty files a 15% boost to make them appear higher in results
|
||||
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
|
||||
@@ -233,56 +223,68 @@ pub fn match_and_score_files<'a>(
|
||||
};
|
||||
|
||||
let distance_penalty =
|
||||
calculate_distance_penalty(context.current_file, &file.relative_path);
|
||||
calculate_distance_penalty(context.current_file, file.relative_path());
|
||||
|
||||
let filename_match = filename_matches
|
||||
.get(next_filename_match_index)
|
||||
.and_then(|m| {
|
||||
if m.index == index as u32 {
|
||||
next_filename_match_index += 1;
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let filename_start = file.filename_offset_in_relative() as u16;
|
||||
let match_start_approx = path_match.end_col.saturating_sub(main_needle_len - 1);
|
||||
|
||||
let mut has_special_filename_bonus = false;
|
||||
let filename_bonus = match filename_match {
|
||||
Some(filename_match) if filename_match.exact => {
|
||||
filename_match.score as i32 / 5 * 2 // 40% bonus for exact filename match
|
||||
}
|
||||
// 16% bonus for fuzzy filename match but only if the score of matched path is
|
||||
// equal or greater than the score of matched filename, thus we are not allowing
|
||||
// typoed filename to score higher than the path match
|
||||
Some(filename_match)
|
||||
if filename_match.score >= path_match.score
|
||||
&& !query_contains_path_separator =>
|
||||
{
|
||||
base_score = filename_match.score as i32;
|
||||
|
||||
(base_score / 6)
|
||||
// for large queries around ~300 score the bonus is too big
|
||||
// it might lead to situations when much more fitting path with a larger
|
||||
// base score getting filtered out by combination of score + filename bonus
|
||||
// so we cap it at 10% of the roughly largest score you can get
|
||||
.min(30)
|
||||
}
|
||||
// 5% bonus for special file but not as much as file name to avoid sitatuions
|
||||
// when you have /user_service/server.rs and /user_service/server/mod.rs
|
||||
None if is_special_entry_point_file(&file.file_name) => {
|
||||
has_special_filename_bonus = true;
|
||||
base_score * 5 / 100
|
||||
}
|
||||
_ => 0,
|
||||
let end_col_filename_match = match_start_approx >= filename_start;
|
||||
let simd_filename_match = if !end_col_filename_match {
|
||||
filename_fallback_matches
|
||||
.get(next_filename_match_cursor)
|
||||
.and_then(|m| {
|
||||
if fallback_indices[m.index as usize] == match_idx as u32 {
|
||||
next_filename_match_cursor += 1;
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let current_file_penalty = calculate_current_file_penalty(file, base_score, context);
|
||||
let is_filename_match = end_col_filename_match || simd_filename_match.is_some();
|
||||
let is_exact_filename = simd_filename_match.is_some_and(|m| m.exact)
|
||||
|| (end_col_filename_match
|
||||
&& main_needle_len as usize == file.file_name().len()
|
||||
&& main_needle.eq_ignore_ascii_case(file.file_name().as_bytes()));
|
||||
|
||||
let mut has_special_filename_bonus = false;
|
||||
let filename_bonus = if is_exact_filename {
|
||||
base_score / 5 * 2 // 40% bonus for exact filename match
|
||||
} else if is_filename_match {
|
||||
// 16% bonus for fuzzy filename match that landed in the filename region.
|
||||
// For fallback matches (where the path match landed in a directory segment),
|
||||
// scale the bonus by the quality of the filename match — a contiguous match
|
||||
// like "rename" in "rename.ts" gets the full bonus, while a scattered
|
||||
// subsequence like r-e-n-a-m-e in "generateSessionName.ts" gets much less.
|
||||
let max_bonus = (base_score / 6).min(30);
|
||||
if let Some(fm) = simd_filename_match {
|
||||
let max_possible = main_needle_len as i32 * 16;
|
||||
let quality = (fm.score as i32).min(max_possible);
|
||||
max_bonus * quality / max_possible
|
||||
} else {
|
||||
max_bonus
|
||||
}
|
||||
} else if !is_filename_match && is_special_entry_point_file(file.file_name()) {
|
||||
// 5% bonus for special file but not as much as file name to avoid situations
|
||||
// when you have /user_service/server.rs and /user_service/server/mod.rs
|
||||
has_special_filename_bonus = true;
|
||||
base_score * 5 / 100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Light penalty for the current file — just enough to demote it slightly,
|
||||
// not enough to bury it when the query is a good match.
|
||||
let current_file_penalty =
|
||||
calculate_current_file_penalty(file, base_score / 4, context);
|
||||
let combo_match_boost = {
|
||||
let last_same_query_match = context
|
||||
.last_same_query_match
|
||||
.as_ref()
|
||||
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
|
||||
.filter(|m| m.file_path.as_os_str() == file.as_path().as_os_str());
|
||||
|
||||
match last_same_query_match {
|
||||
// if we request a combo match without a boost we have to render it anyway
|
||||
@@ -297,13 +299,42 @@ pub fn match_and_score_files<'a>(
|
||||
}
|
||||
};
|
||||
|
||||
// Path alignment bonus: when the query looks like a file path,
|
||||
// reward candidates whose path closely matches the typed query.
|
||||
// Uses suffix overlap — bytes matching from the end. A full prefix
|
||||
// match is just the 100% coverage case, so no separate branch needed.
|
||||
let path_alignment_bonus = if query_contains_path_separator {
|
||||
let path_bytes = file.relative_path().as_bytes();
|
||||
let common_suffix = main_needle
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(path_bytes.iter().rev())
|
||||
.take_while(|(n, p)| n.eq_ignore_ascii_case(p))
|
||||
.count();
|
||||
|
||||
let needle_len = main_needle.len();
|
||||
if common_suffix > 10 && needle_len > 0 {
|
||||
let coverage = common_suffix * 100 / needle_len;
|
||||
if coverage >= 30 {
|
||||
base_score * coverage as i32 / 100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let total = base_score
|
||||
.saturating_add(frecency_boost)
|
||||
.saturating_add(git_status_boost)
|
||||
.saturating_add(distance_penalty)
|
||||
.saturating_add(filename_bonus)
|
||||
.saturating_add(current_file_penalty)
|
||||
.saturating_add(combo_match_boost);
|
||||
.saturating_add(combo_match_boost)
|
||||
.saturating_add(path_alignment_bonus);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
@@ -319,12 +350,16 @@ pub fn match_and_score_files<'a>(
|
||||
git_status_boost,
|
||||
distance_penalty,
|
||||
combo_match_boost,
|
||||
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
|
||||
match_type: match filename_match {
|
||||
Some(filename_match) if filename_match.exact => "exact_filename",
|
||||
Some(_) => "fuzzy_filename",
|
||||
None if path_match.exact => "exact_path",
|
||||
None => "fuzzy_path",
|
||||
path_alignment_bonus,
|
||||
exact_match: is_exact_filename || path_match.exact,
|
||||
match_type: if is_exact_filename {
|
||||
"exact_filename"
|
||||
} else if is_filename_match {
|
||||
"fuzzy_filename"
|
||||
} else if path_match.exact {
|
||||
"exact_path"
|
||||
} else {
|
||||
"fuzzy_path"
|
||||
},
|
||||
};
|
||||
|
||||
@@ -389,6 +424,7 @@ pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
distance_penalty: 0,
|
||||
special_filename_bonus: 0,
|
||||
combo_match_boost: 0,
|
||||
path_alignment_bonus: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
git_status_boost,
|
||||
@@ -400,8 +436,16 @@ pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
};
|
||||
|
||||
let results: Vec<_> = match files {
|
||||
FileItems::All(s) => s.par_iter().map(&score_file).collect(),
|
||||
FileItems::Filtered(v) => v.iter().map(|&file| score_file(file)).collect(),
|
||||
FileItems::All(s) => s
|
||||
.par_iter()
|
||||
.filter(|f| !f.is_deleted())
|
||||
.map(&score_file)
|
||||
.collect(),
|
||||
FileItems::Filtered(v) => v
|
||||
.iter()
|
||||
.filter(|f| !f.is_deleted())
|
||||
.map(|&file| score_file(file))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
sort_and_paginate(results, context)
|
||||
@@ -416,12 +460,9 @@ fn calculate_current_file_penalty(
|
||||
let mut penalty = 0i32;
|
||||
|
||||
if let Some(current) = context.current_file
|
||||
&& file.relative_path.as_str() == current
|
||||
&& file.relative_path() == current
|
||||
{
|
||||
penalty -= match file.git_status {
|
||||
Some(status) if is_modified_status(status) => base_score / 2,
|
||||
_ => base_score,
|
||||
};
|
||||
penalty -= base_score;
|
||||
}
|
||||
|
||||
penalty
|
||||
@@ -499,14 +540,15 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::types::PaginationArgs;
|
||||
use fff_query_parser::QueryParser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
|
||||
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
|
||||
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
|
||||
let file = FileItem::new_raw(
|
||||
PathBuf::from(path),
|
||||
path.to_string(),
|
||||
file_name,
|
||||
0,
|
||||
filename_start,
|
||||
0,
|
||||
modified,
|
||||
None,
|
||||
@@ -524,13 +566,61 @@ mod tests {
|
||||
exact_match: false,
|
||||
match_type: "test",
|
||||
combo_match_boost: 0,
|
||||
path_alignment_bonus: 0,
|
||||
};
|
||||
(file, score_obj)
|
||||
}
|
||||
|
||||
fn make_file(path: &str) -> FileItem {
|
||||
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
|
||||
FileItem::new_raw(path.to_string(), 0, filename_start, 0, 0, None, false)
|
||||
}
|
||||
|
||||
fn make_file_with_frecency(path: &str, access_frecency: i16) -> FileItem {
|
||||
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
|
||||
let mut file = FileItem::new_raw(path.to_string(), 0, filename_start, 0, 0, None, false);
|
||||
file.access_frecency_score = access_frecency;
|
||||
file
|
||||
}
|
||||
|
||||
/// Run `match_and_score_files` with production-like max_typos scaling.
|
||||
fn search(files: &[FileItem], query: &str) -> Vec<(String, Score)> {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
|
||||
let effective_query = match &parsed.fuzzy_query {
|
||||
FuzzyQuery::Text(t) => *t,
|
||||
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
|
||||
_ => query,
|
||||
};
|
||||
let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
|
||||
|
||||
let ctx = ScoringContext {
|
||||
query: &parsed,
|
||||
max_threads: 1,
|
||||
max_typos,
|
||||
current_file: None,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
};
|
||||
let (items, scores, _) = match_and_score_files(files, &ctx);
|
||||
items
|
||||
.iter()
|
||||
.zip(scores.iter())
|
||||
.map(|(f, s)| (f.relative_path().to_string(), s.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Sort / pagination ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_partial_sort_descending() {
|
||||
// Create test data with known scores
|
||||
let test_data = vec![
|
||||
create_test_file("file1.rs", 100, 1000),
|
||||
create_test_file("file2.rs", 200, 2000),
|
||||
@@ -544,7 +634,6 @@ mod tests {
|
||||
create_test_file("file10.rs", 90, 10000),
|
||||
];
|
||||
|
||||
// Convert to references like the actual function uses
|
||||
let results: Vec<(&FileItem, Score)> = test_data
|
||||
.iter()
|
||||
.map(|(file, score)| (file, score.clone()))
|
||||
@@ -562,38 +651,32 @@ mod tests {
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Test with full sort - returns all results sorted descending
|
||||
let (items, scores, total) = sort_and_paginate(results.clone(), &context);
|
||||
|
||||
// Should return all 10 items sorted by score descending
|
||||
assert_eq!(total, 10);
|
||||
assert_eq!(scores.len(), 10);
|
||||
assert_eq!(scores[0].total, 300, "First should be highest score");
|
||||
assert_eq!(scores[1].total, 250, "Second should be second highest");
|
||||
assert_eq!(scores[2].total, 200, "Third should be third highest");
|
||||
|
||||
// Verify the files match
|
||||
assert_eq!(items[0].relative_path, "file4.rs");
|
||||
assert_eq!(items[1].relative_path, "file6.rs");
|
||||
assert_eq!(items[2].relative_path, "file2.rs");
|
||||
assert_eq!(items[0].relative_path(), "file4.rs");
|
||||
assert_eq!(items[1].relative_path(), "file6.rs");
|
||||
assert_eq!(items[2].relative_path(), "file2.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_sort_with_same_scores() {
|
||||
// Test tiebreaker with modified time
|
||||
let test_data = [
|
||||
create_test_file("file1.rs", 100, 5000), // Same score, older
|
||||
create_test_file("file2.rs", 100, 8000), // Same score, newer
|
||||
create_test_file("file3.rs", 100, 3000), // Same score, oldest
|
||||
create_test_file("file1.rs", 100, 5000),
|
||||
create_test_file("file2.rs", 100, 8000),
|
||||
create_test_file("file3.rs", 100, 3000),
|
||||
create_test_file("file4.rs", 200, 1000),
|
||||
create_test_file("file5.rs", 200, 9000), // Higher score, newest
|
||||
create_test_file("file5.rs", 200, 9000),
|
||||
];
|
||||
|
||||
let results: Vec<(&FileItem, Score)> = test_data
|
||||
@@ -613,7 +696,6 @@ mod tests {
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
@@ -622,7 +704,6 @@ mod tests {
|
||||
|
||||
let (items, scores, _) = sort_and_paginate(results, &context);
|
||||
|
||||
// Should return all 5 items sorted: 200(9000), 200(1000), 100(8000), 100(5000), 100(3000)
|
||||
assert_eq!(scores.len(), 5);
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(items[0].modified, 9000, "First 200 should be newest");
|
||||
@@ -638,7 +719,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_no_partial_sort_for_small_results() {
|
||||
// When results.len() <= threshold, should use regular sort
|
||||
let test_data = [
|
||||
create_test_file("file1.rs", 100, 1000),
|
||||
create_test_file("file2.rs", 200, 2000),
|
||||
@@ -662,62 +742,158 @@ mod tests {
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Returns all results sorted descending
|
||||
let (items, scores, _) = sort_and_paginate(results, &context);
|
||||
|
||||
assert_eq!(scores.len(), 3);
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(scores[1].total, 100);
|
||||
assert_eq!(scores[2].total, 50);
|
||||
assert_eq!(items[0].relative_path, "file2.rs");
|
||||
assert_eq!(items[1].relative_path, "file1.rs");
|
||||
assert_eq!(items[2].relative_path, "file3.rs");
|
||||
assert_eq!(items[0].relative_path(), "file2.rs");
|
||||
assert_eq!(items[1].relative_path(), "file1.rs");
|
||||
assert_eq!(items[2].relative_path(), "file3.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_match_ranks_above_path_only_match() {
|
||||
let files = vec![
|
||||
make_file("src/username/handler.rs"),
|
||||
make_file("src/username/username.rs"),
|
||||
];
|
||||
|
||||
let results = search(&files, "usrnmea");
|
||||
|
||||
assert!(
|
||||
results.len() >= 2,
|
||||
"both files should match, got {}",
|
||||
results.len()
|
||||
);
|
||||
assert_eq!(
|
||||
results[0].0, "src/username/username.rs",
|
||||
"filename match should rank first"
|
||||
);
|
||||
assert!(
|
||||
results[0].1.filename_bonus > 0,
|
||||
"username.rs should have filename bonus"
|
||||
);
|
||||
assert_eq!(
|
||||
results[1].1.filename_bonus, 0,
|
||||
"handler.rs should have no filename bonus"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_filename_beats_fuzzy_filename() {
|
||||
let files = vec![
|
||||
make_file("src/user_name_handler.rs"),
|
||||
make_file("src/username.rs"),
|
||||
];
|
||||
|
||||
let results = search(&files, "username.rs");
|
||||
|
||||
assert!(results.len() >= 2);
|
||||
assert_eq!(
|
||||
results[0].0, "src/username.rs",
|
||||
"exact filename should rank first"
|
||||
);
|
||||
assert_eq!(results[0].1.match_type, "exact_filename");
|
||||
assert!(results[0].1.filename_bonus > results[1].1.filename_bonus);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_length_filename_no_false_exact() {
|
||||
let files = vec![
|
||||
make_file("src/item_sync/file.rs"),
|
||||
make_file("src/models/item.rs"),
|
||||
];
|
||||
|
||||
let results = search(&files, "item.rs");
|
||||
|
||||
assert!(results.len() >= 2);
|
||||
assert_eq!(results[0].0, "src/models/item.rs");
|
||||
assert_eq!(results[0].1.match_type, "exact_filename");
|
||||
assert_ne!(
|
||||
results[1].1.match_type, "exact_filename",
|
||||
"file.rs should not get exact_filename"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_separator_disables_filename_bonus() {
|
||||
let files = vec![make_file("src/controllers/user.rs")];
|
||||
|
||||
let results = search(&files, "src/user");
|
||||
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(
|
||||
results[0].1.filename_bonus, 0,
|
||||
"path-like query should not get filename bonus"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: full-path query should rank the near-exact path match first.
|
||||
/// https://x.com/mbarneyjr/status/2043474268390817861
|
||||
#[test]
|
||||
fn test_full_path_query_prefers_closer_filename_match() {
|
||||
let files = vec![
|
||||
make_file("test-utils/completion/condition-key/yaml_partial-svc-colon.yml"),
|
||||
make_file("test-utils/test-cases/completion/condition-key/yaml_partial-svc.yml"),
|
||||
make_file("test-utils/action-value/yaml_inline_partial-svc-colon.yml"),
|
||||
make_file("test-utils/completion/action-value/yaml_array_partial-svc-colon.yml"),
|
||||
make_file("test-utils/completion/action-value/yaml_array_partial-svc.yml"),
|
||||
make_file("test-utils/completion/condition-key/yaml_global-tag-keys.yml"),
|
||||
make_file_with_frecency(
|
||||
"test-utils/test-cases/completion/condition-key/yaml_partial.yml",
|
||||
10,
|
||||
),
|
||||
];
|
||||
|
||||
let results = search(
|
||||
&files,
|
||||
"t-utils/test-cases/completion/condition-key/yaml_partial-svc.yml",
|
||||
);
|
||||
|
||||
assert!(!results.is_empty(), "query should match at least one file");
|
||||
|
||||
assert_eq!(
|
||||
results[0].0, "test-utils/test-cases/completion/condition-key/yaml_partial-svc.yml",
|
||||
"near-exact full-path match should rank first, but got: {} \
|
||||
(total={}, base={}, frecency={})",
|
||||
results[0].0, results[0].1.total, results[0].1.base_score, results[0].1.frecency_boost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_part_tests {
|
||||
#[test]
|
||||
fn test_single_path_matching() {
|
||||
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs";
|
||||
|
||||
// Test with max_typos = 2 (safe for short needles)
|
||||
let options = neo_frizbee::Config {
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Test "aipart" matching
|
||||
let matches = neo_frizbee::match_list("aipart", &[path], &options);
|
||||
println!("'aipart' matches (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'aipart' should match the path");
|
||||
|
||||
// Test "core" matching
|
||||
let matches = neo_frizbee::match_list("core", &[path], &options);
|
||||
println!("'core' matches (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'core' should match the path");
|
||||
|
||||
// Test "co" matching - need max_typos <= needle.len()
|
||||
let co_options = neo_frizbee::Config {
|
||||
max_typos: Some(2), // Safe: 2 <= len("co") = 2
|
||||
max_typos: Some(2),
|
||||
..options
|
||||
};
|
||||
let matches = neo_frizbee::match_list("co", &[path], &co_options);
|
||||
println!("'co' matches (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'co' should match the path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lowercase_path_matching() {
|
||||
// The actual paths are lowercased
|
||||
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs".to_lowercase();
|
||||
|
||||
let options = neo_frizbee::Config {
|
||||
@@ -726,14 +902,10 @@ mod multi_part_tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Test "co" matching on lowercase path
|
||||
let matches = neo_frizbee::match_list("co", &[path.as_str()], &options);
|
||||
println!("'co' matches lowercase path (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'co' should match the lowercase path");
|
||||
|
||||
// Test "core" matching on lowercase path
|
||||
let matches = neo_frizbee::match_list("core", &[path.as_str()], &options);
|
||||
println!("'core' matches lowercase path (max_typos=2): {:?}", matches);
|
||||
assert!(
|
||||
!matches.is_empty(),
|
||||
"'core' should match the lowercase path"
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
|
||||
/// Thread-safe shared handle to the [`FilePicker`] instance.
|
||||
///
|
||||
/// Uses `parking_lot::RwLock` which is reader-fair — new readers are not
|
||||
/// blocked when a writer is waiting, preventing search query stalls during
|
||||
/// background bigram builds or watcher writes.
|
||||
///
|
||||
/// `Clone` gives a new handle to the same picker (Arc clone).
|
||||
/// `Default` creates an empty handle suitable for `Lazy::new(SharedPicker::default)`.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SharedPicker(pub(crate) Arc<parking_lot::RwLock<Option<FilePicker>>>);
|
||||
|
||||
impl std::fmt::Debug for SharedPicker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedPicker").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedPicker {
|
||||
pub fn read(&self) -> Result<parking_lot::RwLockReadGuard<'_, Option<FilePicker>>, Error> {
|
||||
Ok(self.0.read())
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, Option<FilePicker>>, Error> {
|
||||
Ok(self.0.write())
|
||||
}
|
||||
|
||||
/// Block until the background filesystem scan finishes.
|
||||
/// Returns `true` if scan completed, `false` on timeout.
|
||||
pub fn wait_for_scan(&self, timeout: Duration) -> bool {
|
||||
let signal = {
|
||||
let guard = self.0.read();
|
||||
match &*guard {
|
||||
Some(picker) => picker.scan_signal(),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Block until the background file watcher is ready.
|
||||
/// Returns `true` if watcher ready, `false` on timeout.
|
||||
pub fn wait_for_watcher(&self, timeout: Duration) -> bool {
|
||||
let signal = {
|
||||
let guard = self.0.read();
|
||||
match &*guard {
|
||||
Some(picker) => picker.watcher_signal(),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while !signal.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if start.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Refresh git statuses for all indexed files.
|
||||
pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result<usize, Error> {
|
||||
use git2::StatusOptions;
|
||||
use tracing::debug;
|
||||
|
||||
let git_status = {
|
||||
let guard = self.read()?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Refreshing git statuses for picker: {:?}",
|
||||
picker.git_root()
|
||||
);
|
||||
|
||||
GitStatusCache::read_git_status(
|
||||
picker.git_root(),
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true),
|
||||
)
|
||||
};
|
||||
|
||||
let mut guard = self.write()?;
|
||||
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
|
||||
|
||||
let statuses_count = if let Some(git_status) = git_status {
|
||||
let count = git_status.statuses_len();
|
||||
picker.update_git_statuses(git_status, shared_frecency)?;
|
||||
count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(statuses_count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
#[derive(Clone)]
|
||||
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 {
|
||||
f.debug_tuple("SharedFrecency").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
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.inner.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<FrecencyTracker>>, Error> {
|
||||
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
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 {
|
||||
f.debug_tuple("SharedQueryTracker").field(&"..").finish()
|
||||
}
|
||||
}
|
||||
|
||||
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.inner.read().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<QueryTracker>>, Error> {
|
||||
self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_key_with_buffer() {
|
||||
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
|
||||
sort_by_key_with_buffer(&mut data, |item| item.0);
|
||||
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
|
||||
let mut data = vec![(1, 50), (2, 20), (3, 80), (4, 10), (5, 90)];
|
||||
sort_by_key_with_buffer(&mut data, |a| a.1);
|
||||
assert_eq!(data, vec![(4, 10), (2, 20), (1, 50), (3, 80), (5, 90)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+209
-91
@@ -1,22 +1,26 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
use neo_frizbee::Matchable;
|
||||
|
||||
/// Cached file contents — mmap on Unix, heap buffer on Windows.
|
||||
///
|
||||
/// On Windows, memory-mapped files hold the file handle open and prevent
|
||||
/// editors from saving (writing/replacing) those files. Reading into a
|
||||
/// `Vec<u8>` releases the handle immediately after the read completes.
|
||||
///
|
||||
/// The `Buffer` variant is also used on Unix for temporary (uncached) reads
|
||||
/// where the mmap/munmap syscall overhead exceeds the cost of a heap copy.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)] // variants are conditionally used per platform
|
||||
pub enum FileContent {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
Mmap(memmap2::Mmap),
|
||||
#[cfg(target_os = "windows")]
|
||||
Buffer(Vec<u8>),
|
||||
}
|
||||
|
||||
@@ -26,13 +30,22 @@ impl std::ops::Deref for FileContent {
|
||||
match self {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
FileContent::Mmap(m) => m,
|
||||
#[cfg(target_os = "windows")]
|
||||
FileContent::Buffer(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileItemFlags;
|
||||
|
||||
impl FileItemFlags {
|
||||
pub const BINARY: u8 = 1 << 0;
|
||||
/// Tombstone — file was deleted but index slot is preserved so
|
||||
/// bigram indices for other files stay valid.
|
||||
pub const DELETED: u8 = 1 << 1;
|
||||
}
|
||||
|
||||
/// A single indexed file with metadata, frecency scores, and lazy content cache.
|
||||
/// Occupies ~100 bytes + file path per file
|
||||
///
|
||||
/// File contents are initialized lazily on the first grep access and cached for
|
||||
/// subsequent searches. On Unix, uses mmap backed by the kernel page cache. On
|
||||
@@ -42,18 +55,26 @@ impl std::ops::Deref for FileContent {
|
||||
/// Each file is only searched by one rayon worker at a time via `par_iter`.
|
||||
#[derive(Debug)]
|
||||
pub struct FileItem {
|
||||
pub path: PathBuf,
|
||||
pub relative_path: String,
|
||||
pub relative_path_lower: String,
|
||||
pub file_name: String,
|
||||
pub file_name_lower: String,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
/// Modification time in UNIX timestamp
|
||||
pub modified: u64,
|
||||
pub access_frecency_score: i64,
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
/// Frecency access score
|
||||
pub access_frecency_score: i16,
|
||||
/// Frecency modification score
|
||||
pub modification_frecency_score: i16,
|
||||
/// The file's git status
|
||||
pub git_status: Option<git2::Status>,
|
||||
pub is_binary: bool,
|
||||
|
||||
/// Absolute path stored as a plain String. We never use path components —
|
||||
/// only slicing, comparison, and passing to fs/DB APIs via `as_path()`.
|
||||
path: String,
|
||||
/// Byte offset where the relative path begins (after base_path + separator).
|
||||
relative_start: u16,
|
||||
/// Byte offset where the filename begins (after last separator).
|
||||
filename_start: u16,
|
||||
/// Packed boolean flags — see `FileItemFlags`.
|
||||
flags: u8,
|
||||
/// Lazily-initialized file contents for grep.
|
||||
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
|
||||
content: OnceLock<FileContent>,
|
||||
@@ -63,77 +84,130 @@ impl Clone for FileItem {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
path: self.path.clone(),
|
||||
relative_path: self.relative_path.clone(),
|
||||
relative_path_lower: self.relative_path_lower.clone(),
|
||||
file_name: self.file_name.clone(),
|
||||
file_name_lower: self.file_name_lower.clone(),
|
||||
relative_start: self.relative_start,
|
||||
filename_start: self.filename_start,
|
||||
size: self.size,
|
||||
modified: self.modified,
|
||||
access_frecency_score: self.access_frecency_score,
|
||||
modification_frecency_score: self.modification_frecency_score,
|
||||
total_frecency_score: self.total_frecency_score,
|
||||
git_status: self.git_status,
|
||||
is_binary: self.is_binary,
|
||||
flags: self.flags,
|
||||
// Don't clone the content — the clone lazily re-creates it on demand
|
||||
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: PathBuf,
|
||||
relative_path: String,
|
||||
file_name: String,
|
||||
path: String,
|
||||
relative_start: u16,
|
||||
filename_start: u16,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
is_binary: bool,
|
||||
) -> Self {
|
||||
let mut flags = 0u8;
|
||||
if is_binary {
|
||||
flags |= FileItemFlags::BINARY;
|
||||
}
|
||||
|
||||
Self {
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
file_name_lower: file_name.to_lowercase(),
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
is_binary,
|
||||
flags,
|
||||
content: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The full absolute path as a string slice.
|
||||
#[inline]
|
||||
pub fn path_str(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The full absolute path as a `&Path` (zero-cost on Unix).
|
||||
#[inline]
|
||||
pub fn as_path(&self) -> &Path {
|
||||
Path::new(&self.path)
|
||||
}
|
||||
|
||||
/// The relative path (from the base directory).
|
||||
#[inline]
|
||||
pub fn relative_path(&self) -> &str {
|
||||
&self.path[self.relative_start as usize..]
|
||||
}
|
||||
|
||||
/// Just the filename component.
|
||||
#[inline]
|
||||
pub fn file_name(&self) -> &str {
|
||||
&self.path[self.filename_start as usize..]
|
||||
}
|
||||
|
||||
/// Byte offset of the filename within the relative path.
|
||||
/// Equivalent to `relative_path().len() - file_name().len()`.
|
||||
#[inline]
|
||||
pub fn filename_offset_in_relative(&self) -> usize {
|
||||
(self.filename_start - self.relative_start) as usize
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn total_frecency_score(&self) -> i32 {
|
||||
self.access_frecency_score as i32 + self.modification_frecency_score as i32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_binary(&self) -> bool {
|
||||
self.flags & FileItemFlags::BINARY != 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_binary(&mut self, val: bool) {
|
||||
if val {
|
||||
self.flags |= FileItemFlags::BINARY;
|
||||
} else {
|
||||
self.flags &= !FileItemFlags::BINARY;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_deleted(&self) -> bool {
|
||||
self.flags & FileItemFlags::DELETED != 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_deleted(&mut self, val: bool) {
|
||||
if val {
|
||||
self.flags |= FileItemFlags::DELETED;
|
||||
} else {
|
||||
self.flags &= !FileItemFlags::DELETED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Matchable for FileItem {
|
||||
#[inline]
|
||||
fn match_str(&self) -> Option<&str> {
|
||||
(!self.is_deleted()).then(|| self.relative_path())
|
||||
}
|
||||
}
|
||||
|
||||
impl Matchable for &FileItem {
|
||||
#[inline]
|
||||
fn match_str(&self) -> Option<&str> {
|
||||
(!self.is_deleted()).then(|| self.relative_path())
|
||||
}
|
||||
}
|
||||
|
||||
impl FileItem {
|
||||
/// Invalidate the cached content so the next `get_content()` call creates a fresh one.
|
||||
///
|
||||
/// Call this when the background watcher detects that the file has been modified.
|
||||
@@ -145,6 +219,7 @@ impl FileItem {
|
||||
budget.cached_count.fetch_sub(1, Ordering::Relaxed);
|
||||
budget.cached_bytes.fetch_sub(self.size, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
self.content = OnceLock::new();
|
||||
}
|
||||
|
||||
@@ -155,24 +230,26 @@ 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).
|
||||
#[inline]
|
||||
pub fn get_content(&self, budget: &ContentCacheBudget) -> Option<&[u8]> {
|
||||
if let Some(content) = self.content.get() {
|
||||
return Some(content);
|
||||
}
|
||||
|
||||
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
|
||||
let max_file_size = budget.max_file_size;
|
||||
if self.size == 0 || self.size > max_file_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check cache budget before creating a new persistent cache entry.
|
||||
let count = budget.cached_count.load(Ordering::Relaxed);
|
||||
let bytes = budget.cached_bytes.load(Ordering::Relaxed);
|
||||
if count >= budget.max_files || bytes + self.size > MAX_CACHED_CONTENT_BYTES {
|
||||
let max_files = budget.max_files;
|
||||
let max_bytes = budget.max_bytes;
|
||||
if count >= max_files || bytes + self.size > max_bytes {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = load_file_content(&self.path)?;
|
||||
let content = load_file_content(self.as_path(), self.size)?;
|
||||
let result = self.content.get_or_init(|| content);
|
||||
|
||||
// Bump counters. Slight over-count under races is fine — the budget
|
||||
@@ -183,71 +260,87 @@ impl FileItem {
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Backward-compatible alias for `get_content`.
|
||||
#[inline]
|
||||
pub fn get_mmap(&self, budget: &ContentCacheBudget) -> Option<&[u8]> {
|
||||
self.get_content(budget)
|
||||
}
|
||||
|
||||
/// Get file content for searching — **always returns content** for eligible
|
||||
/// files, even when the persistent cache budget is exhausted.
|
||||
///
|
||||
/// Tries the `OnceLock` cache first (fast path). If the cache is full,
|
||||
/// falls back to a temporary mmap that is unmapped when the returned
|
||||
/// [`FileContentRef`] is dropped — no persistent kernel resources retained.
|
||||
#[inline]
|
||||
pub fn get_content_for_search<'a>(
|
||||
&'a self,
|
||||
buf: &'a mut Vec<u8>,
|
||||
budget: &ContentCacheBudget,
|
||||
) -> Option<FileContentRef<'a>> {
|
||||
) -> Option<&'a [u8]> {
|
||||
// Fast path: persistent cache hit (zero-copy).
|
||||
if let Some(cached) = self.get_content(budget) {
|
||||
return Some(FileContentRef::Cached(cached));
|
||||
return Some(cached);
|
||||
}
|
||||
|
||||
// get_content returned None — either ineligible or over budget.
|
||||
if self.is_binary || self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
|
||||
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.path)?;
|
||||
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 len = self.size as usize;
|
||||
buf.resize(len, 0);
|
||||
let mut file = std::fs::File::open(self.as_path()).ok()?;
|
||||
file.read_exact(buf).ok()?;
|
||||
Some(buf.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
/// Load file contents: mmap on Unix, heap buffer on Windows.
|
||||
fn load_file_content(path: &Path) -> Option<FileContent> {
|
||||
/// Page size on Apple Silicon is 16KB; on x86-64 it's 4KB.
|
||||
/// Files smaller than one page waste the remainder when mmapped.
|
||||
/// Reading them into a heap buffer avoids this overhead.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
const MMAP_THRESHOLD: u64 = 16 * 1024;
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
const MMAP_THRESHOLD: u64 = 4 * 1024;
|
||||
|
||||
/// Load file contents: small files are read into a heap buffer to avoid
|
||||
/// mmap page alignment waste; large files use mmap for zero-copy access.
|
||||
/// On Windows, always uses heap buffer (mmap holds the file handle open).
|
||||
fn load_file_content(path: &Path, size: u64) -> Option<FileContent> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
// SAFETY: The mmap is backed by the kernel page cache and automatically
|
||||
// reflects file modifications. The only risk is SIGBUS if the file is
|
||||
// truncated while mapped.
|
||||
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
|
||||
Some(FileContent::Mmap(mmap))
|
||||
if size < MMAP_THRESHOLD {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
Some(FileContent::Buffer(data))
|
||||
} else {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
// SAFETY: The mmap is backed by the kernel page cache and automatically
|
||||
// reflects file modifications. The only risk is SIGBUS if the file is
|
||||
// truncated while mapped.
|
||||
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
|
||||
Some(FileContent::Mmap(mmap))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = size;
|
||||
let data = std::fs::read(path).ok()?;
|
||||
Some(FileContent::Buffer(data))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for FileItem {
|
||||
#[inline]
|
||||
fn as_ref(&self) -> &Path {
|
||||
Path::new(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Constrainable for FileItem {
|
||||
#[inline]
|
||||
fn relative_path(&self) -> &str {
|
||||
&self.relative_path
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn relative_path_lower(&self) -> &str {
|
||||
&self.relative_path_lower
|
||||
FileItem::relative_path(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn file_name(&self) -> &str {
|
||||
&self.file_name
|
||||
FileItem::file_name(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -267,6 +360,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,
|
||||
}
|
||||
@@ -345,6 +439,8 @@ const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
#[derive(Debug)]
|
||||
pub struct ContentCacheBudget {
|
||||
pub max_files: usize,
|
||||
pub max_bytes: u64,
|
||||
pub max_file_size: u64,
|
||||
pub cached_count: AtomicUsize,
|
||||
pub cached_bytes: AtomicU64,
|
||||
}
|
||||
@@ -355,6 +451,8 @@ impl ContentCacheBudget {
|
||||
pub fn unlimited() -> Self {
|
||||
Self {
|
||||
max_files: usize::MAX,
|
||||
max_bytes: u64::MAX,
|
||||
max_file_size: MAX_MMAP_FILE_SIZE,
|
||||
cached_count: AtomicUsize::new(0),
|
||||
cached_bytes: AtomicU64::new(0),
|
||||
}
|
||||
@@ -363,14 +461,34 @@ impl ContentCacheBudget {
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
max_files: 0,
|
||||
max_bytes: 0,
|
||||
max_file_size: 0,
|
||||
cached_count: AtomicUsize::new(0),
|
||||
cached_bytes: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(max_files: usize) -> Self {
|
||||
pub fn new_for_repo(file_count: usize) -> Self {
|
||||
let max_files = if file_count > 50_000 {
|
||||
5_000
|
||||
} else if file_count > 10_000 {
|
||||
10_000
|
||||
} else {
|
||||
30_000 // effectively unlimited for small repos
|
||||
};
|
||||
|
||||
let max_bytes = if file_count > 50_000 {
|
||||
128 * 1024 * 1024 // 128 MB
|
||||
} else if file_count > 10_000 {
|
||||
256 * 1024 * 1024 // 256 MB
|
||||
} else {
|
||||
MAX_CACHED_CONTENT_BYTES // 512 MB
|
||||
};
|
||||
|
||||
Self {
|
||||
max_files,
|
||||
max_bytes,
|
||||
max_file_size: MAX_MMAP_FILE_SIZE,
|
||||
cached_count: AtomicUsize::new(0),
|
||||
cached_bytes: AtomicU64::new(0),
|
||||
}
|
||||
@@ -386,6 +504,6 @@ impl ContentCacheBudget {
|
||||
|
||||
impl Default for ContentCacheBudget {
|
||||
fn default() -> Self {
|
||||
Self::new(30_000)
|
||||
Self::new_for_repo(30_000)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
//! Integration test: verify that modifying a file after the bigram index is built
|
||||
//! still makes the new content findable via grep (through the overlay layer).
|
||||
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use fff_search::file_picker::{FFFMode, FilePicker};
|
||||
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
|
||||
use fff_search::{FilePickerOptions, SharedFrecency, SharedPicker};
|
||||
|
||||
/// Create a temp directory with some initial files, run the full picker lifecycle,
|
||||
/// then modify a file and verify grep finds the new content.
|
||||
#[test]
|
||||
fn modified_file_findable_via_overlay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
// Create initial files with known content.
|
||||
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
|
||||
fs::write(
|
||||
base.join("beta.txt"),
|
||||
"some other content\nnothing special\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("gamma.txt"), "yet another file\nmore lines\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to create FilePicker");
|
||||
|
||||
// Wait for scan + bigram build to complete.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let ready = shared_picker
|
||||
.read()
|
||||
.ok()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if ready {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Timed out waiting for scan + bigram build"
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity check: the 3 files are indexed.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
assert_eq!(picker.get_files().len(), 3, "Expected 3 files after scan");
|
||||
assert!(
|
||||
picker.bigram_index().is_some(),
|
||||
"Bigram index should be built"
|
||||
);
|
||||
assert!(
|
||||
picker.bigram_overlay().is_some(),
|
||||
"Overlay should be initialized"
|
||||
);
|
||||
}
|
||||
|
||||
// "UNIQUE_NEEDLE" should NOT exist in any file yet.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let parsed = parse_grep_query("UNIQUE_NEEDLE");
|
||||
let opts = grep_opts();
|
||||
let result = picker.grep(&parsed, &opts);
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
0,
|
||||
"UNIQUE_NEEDLE should not exist before modification"
|
||||
);
|
||||
}
|
||||
|
||||
// Sleep so the filesystem mtime (seconds granularity) advances past the
|
||||
// value recorded during scan. Without this, on_create_or_modify skips
|
||||
// mmap invalidation and grep reads stale cached content.
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
|
||||
// Write new content containing the needle.
|
||||
let modified_path = base.join("beta.txt");
|
||||
fs::write(
|
||||
&modified_path,
|
||||
"some other content\nUNIQUE_NEEDLE is here\nnothing special\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Simulate watcher event: call on_create_or_modify.
|
||||
// This updates the overlay's bigrams and invalidates the mmap cache.
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
let result = picker.on_create_or_modify(&modified_path);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"on_create_or_modify should return the file"
|
||||
);
|
||||
}
|
||||
|
||||
// The bigram index was built BEFORE the modification, so without the
|
||||
// overlay, beta.txt would be filtered out (its old bigrams don't contain
|
||||
// "UNIQUE_NEEDLE"). The overlay should fix that.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let parsed = parse_grep_query("UNIQUE_NEEDLE");
|
||||
let opts = grep_opts();
|
||||
let result = picker.grep(&parsed, &opts);
|
||||
assert!(
|
||||
!result.matches.is_empty(),
|
||||
"UNIQUE_NEEDLE should be findable after modification (overlay adds the candidate back)"
|
||||
);
|
||||
// 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
|
||||
// index would filter out beta.txt and the search would miss the needle.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let parsed = parse_grep_query("UNIQUE_NEEDLE");
|
||||
let opts = grep_opts();
|
||||
let result = picker.grep_without_overlay(&parsed, &opts);
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
0,
|
||||
"Without overlay, bigram prefiltering should exclude the modified file"
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup: stop background watcher.
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that deleting a file makes its content un-findable via grep.
|
||||
#[test]
|
||||
fn deleted_file_excluded_via_overlay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
fs::write(base.join("keep.txt"), "keep this content\n").unwrap();
|
||||
fs::write(base.join("remove.txt"), "DELETEME_TOKEN is here\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
wait_for_bigram(&shared_picker);
|
||||
|
||||
// Sanity: DELETEME_TOKEN is findable.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let result = grep_for(picker, "DELETEME_TOKEN");
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
1,
|
||||
"Token should be found before delete"
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the file on disk and via picker.
|
||||
let remove_path = base.join("remove.txt");
|
||||
fs::remove_file(&remove_path).unwrap();
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
assert!(
|
||||
picker.remove_file_by_path(&remove_path),
|
||||
"remove should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
// Token should no longer be found (tombstone in overlay clears the candidate).
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let result = grep_for(picker, "DELETEME_TOKEN");
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
0,
|
||||
"DELETEME_TOKEN should not be found after deletion (tombstone in overlay)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that a newly added file (in overflow) is findable via grep.
|
||||
#[test]
|
||||
fn new_file_findable_after_add() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let base = tmp.path();
|
||||
|
||||
fs::write(base.join("existing.txt"), "original content\n").unwrap();
|
||||
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
wait_for_bigram(&shared_picker);
|
||||
|
||||
// Create a new file on disk after the index was built.
|
||||
let new_path = base.join("newcomer.txt");
|
||||
fs::write(&new_path, "BRAND_NEW_TOKEN lives here\n").unwrap();
|
||||
|
||||
// Simulate watcher detecting the new file.
|
||||
{
|
||||
let mut guard = shared_picker.write().unwrap();
|
||||
let picker = guard.as_mut().unwrap();
|
||||
let result = picker.on_create_or_modify(&new_path);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"on_create_or_modify should return the new file"
|
||||
);
|
||||
}
|
||||
|
||||
// The new file is in overflow, not in the base files slice.
|
||||
// grep_search currently only searches base files, so we need to verify
|
||||
// the overflow file is accessible.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let picker = guard.as_ref().unwrap();
|
||||
let overflow = picker.get_overflow_files();
|
||||
assert_eq!(overflow.len(), 1, "Should have 1 overflow file");
|
||||
assert!(
|
||||
overflow[0].as_path().ends_with("newcomer.txt"),
|
||||
"Overflow file should be newcomer.txt"
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: base.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
wait_for_bigram(&shared_picker);
|
||||
|
||||
// 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 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_for<'a>(picker: &'a FilePicker, query: &str) -> fff_search::grep::GrepResult<'a> {
|
||||
let parsed = parse_grep_query(query);
|
||||
picker.grep(&parsed, &grep_opts())
|
||||
}
|
||||
|
||||
fn wait_for_bigram(shared_picker: &SharedPicker) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let ready = shared_picker
|
||||
.read()
|
||||
.ok()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map_or(false, |p| !p.is_scan_active() && p.bigram_index().is_some())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if ready {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Timed out waiting for bigram build"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
//! 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, SharedFrecency, SharedPicker};
|
||||
|
||||
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 = SharedPicker::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(),
|
||||
warmup_mmap_cache: 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,
|
||||
}
|
||||
}
|
||||
|
||||
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 = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&parsed,
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 1,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|f| f.path_str().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wait_for_bigram(shared_picker: &SharedPicker) {
|
||||
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"]);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ fn plain_opts() -> GrepSearchOptions {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ fn regex_opts() -> GrepSearchOptions {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ fn fuzzy_opts() -> GrepSearchOptions {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +82,9 @@ fn plain_text_finds_exact_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -102,6 +108,9 @@ fn plain_text_smart_case_insensitive() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -127,6 +136,9 @@ fn plain_text_smart_case_sensitive_with_uppercase() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -153,6 +165,9 @@ fn plain_text_regex_metacharacters_are_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -165,6 +180,9 @@ fn plain_text_regex_metacharacters_are_literal() {
|
||||
&parsed2,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result2.matches.len(), 1);
|
||||
assert_eq!(result2.matches[0].line_number, 2);
|
||||
@@ -186,6 +204,9 @@ fn plain_text_dot_is_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -211,6 +232,9 @@ fn plain_text_asterisk_is_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert_eq!(result.matches[0].line_number, 1);
|
||||
@@ -231,6 +255,9 @@ fn plain_text_backslash_is_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
}
|
||||
@@ -250,6 +277,9 @@ fn plain_text_across_multiple_files() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 3);
|
||||
@@ -268,6 +298,9 @@ fn plain_text_highlight_offsets_are_correct() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -291,6 +324,9 @@ fn plain_text_empty_query_returns_no_content_matches() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Empty query in grep returns git-modified welcome state (no content matches)
|
||||
@@ -306,7 +342,14 @@ fn plain_text_binary_files_are_skipped() {
|
||||
content.extend_from_slice(&[0u8; 100]); // NUL bytes make it binary
|
||||
content.extend_from_slice(b"match this text\n");
|
||||
fs::write(&binary_path, &content).unwrap();
|
||||
let binary_file = FileItem::new(binary_path, tmp.path(), None);
|
||||
// In production, binary detection by content happens during bigram build
|
||||
// and sets is_binary = true. Simulate that here with new_raw.
|
||||
let meta = fs::metadata(&binary_path).unwrap();
|
||||
let binary_file = {
|
||||
let p = binary_path.to_string_lossy().into_owned();
|
||||
let rs = (p.len() - "binary.dat".len()) as u16;
|
||||
FileItem::new_raw(p, rs, rs, meta.len(), 0, None, true)
|
||||
};
|
||||
|
||||
let text_file = create_file(tmp.path(), "text.txt", "match this text\n");
|
||||
|
||||
@@ -318,11 +361,14 @@ fn plain_text_binary_files_are_skipped() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Only the text file should be searched, not the binary one
|
||||
assert_eq!(result.files.len(), 1);
|
||||
assert!(result.files[0].relative_path.contains("text.txt"));
|
||||
assert!(result.files[0].relative_path().contains("text.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -338,7 +384,15 @@ fn plain_text_max_matches_per_file() {
|
||||
opts.max_matches_per_file = 5;
|
||||
|
||||
let parsed = parse_grep_query("match_target");
|
||||
let result = grep_search(&files, &parsed, &opts, &ContentCacheBudget::unlimited());
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&opts,
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -360,7 +414,15 @@ fn plain_text_page_limit() {
|
||||
opts.page_limit = 10;
|
||||
|
||||
let parsed = parse_grep_query("target");
|
||||
let result = grep_search(&files, &parsed, &opts, &ContentCacheBudget::unlimited());
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&opts,
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// page_limit is a soft minimum: we always finish the current file, so we
|
||||
// get at least page_limit matches (no data loss) and at most
|
||||
@@ -403,7 +465,15 @@ fn plain_text_file_offset_pagination() {
|
||||
|
||||
loop {
|
||||
let parsed = parse_grep_query("unique_token");
|
||||
let result = grep_search(&files, &parsed, &opts, &ContentCacheBudget::unlimited());
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&opts,
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
for m in &result.matches {
|
||||
let text = m.line_content.trim().to_string();
|
||||
@@ -459,6 +529,9 @@ fn plain_text_line_numbers_are_correct() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 4);
|
||||
@@ -479,7 +552,15 @@ fn plain_text_max_file_size_filter() {
|
||||
opts.max_file_size = 100; // Only allow files up to 100 bytes
|
||||
|
||||
let parsed = parse_grep_query("match_me");
|
||||
let result = grep_search(&files, &parsed, &opts, &ContentCacheBudget::unlimited());
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&opts,
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 0, "large file should be filtered out");
|
||||
assert_eq!(result.filtered_file_count, 0);
|
||||
@@ -502,6 +583,9 @@ fn regex_basic_pattern() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -521,6 +605,9 @@ fn regex_capture_group_matching() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 2);
|
||||
@@ -549,6 +636,9 @@ fn regex_dot_matches_any_char() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -573,6 +663,9 @@ fn regex_alternation() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 2);
|
||||
@@ -596,6 +689,9 @@ fn regex_character_class() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 3);
|
||||
@@ -624,6 +720,9 @@ fn regex_quantifiers() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 3, "should match foo, fooo, foooo");
|
||||
@@ -644,6 +743,9 @@ fn regex_anchors() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -666,6 +768,9 @@ fn regex_anchors_multiword() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -688,6 +793,9 @@ fn regex_highlight_offsets_variable_length() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -716,6 +824,9 @@ fn regex_invalid_pattern_falls_back_to_literal() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Fallback to literal: finds "name(" in "call name(arg)"
|
||||
@@ -737,6 +848,9 @@ fn regex_invalid_pattern_falls_back_to_literal() {
|
||||
&parsed2,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result2.matches.len(), 0);
|
||||
assert!(result2.regex_fallback_error.is_some());
|
||||
@@ -758,6 +872,9 @@ fn regex_smart_case() {
|
||||
&parsed_lower,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result_lower.matches.len(), 3);
|
||||
|
||||
@@ -768,6 +885,9 @@ fn regex_smart_case() {
|
||||
&parsed_upper,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result_upper.matches.len(), 1);
|
||||
}
|
||||
@@ -795,6 +915,9 @@ fn regex_across_multiple_files() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Should match: fn main(), fn helper(), fn test_one(), fn test_two()
|
||||
@@ -819,12 +942,18 @@ fn plain_text_and_regex_agree_on_simple_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let regex_result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(plain_result.matches.len(), regex_result.matches.len());
|
||||
@@ -850,6 +979,9 @@ fn plain_text_escapes_what_regex_does_not() {
|
||||
&parsed_plain,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let parsed_regex = parse_grep_query("\\$100");
|
||||
let regex_result = grep_search(
|
||||
@@ -857,6 +989,9 @@ fn plain_text_escapes_what_regex_does_not() {
|
||||
&parsed_regex,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Plain text should find "$100" literally
|
||||
@@ -884,14 +1019,17 @@ fn grep_with_extension_constraint() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Should only search .rs files
|
||||
for file in &result.files {
|
||||
assert!(
|
||||
file.relative_path.ends_with(".rs"),
|
||||
file.relative_path().ends_with(".rs"),
|
||||
"should only match .rs files, got: {}",
|
||||
file.relative_path
|
||||
file.relative_path()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
@@ -917,6 +1055,9 @@ fn plain_text_bracket_is_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -944,6 +1085,9 @@ fn grep_backslash_escapes_extension_filter() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
result_filter.files.len(),
|
||||
@@ -958,6 +1102,9 @@ fn grep_backslash_escapes_extension_filter() {
|
||||
&parsed_escaped,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
result_literal.matches.len(),
|
||||
@@ -981,6 +1128,9 @@ fn grep_backslash_escapes_path_segment() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -1005,6 +1155,9 @@ fn grep_backslash_escapes_negation() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert!(result.matches[0].line_content.contains("!test"));
|
||||
@@ -1025,10 +1178,13 @@ fn grep_with_path_constraint() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert!(result.files[0].relative_path.starts_with("src/"));
|
||||
assert!(result.files[0].relative_path().starts_with("src/"));
|
||||
}
|
||||
|
||||
// ── Negated constraint tests ───────────────────────────────────────────
|
||||
@@ -1049,6 +1205,9 @@ fn grep_with_negated_extension_constraint() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1058,9 +1217,9 @@ fn grep_with_negated_extension_constraint() {
|
||||
result.matches.len()
|
||||
);
|
||||
assert!(
|
||||
result.files[0].relative_path.ends_with(".ts"),
|
||||
result.files[0].relative_path().ends_with(".ts"),
|
||||
"should only match .ts file, got: {}",
|
||||
result.files[0].relative_path
|
||||
result.files[0].relative_path()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1080,6 +1239,9 @@ fn grep_with_negated_path_constraint() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1089,9 +1251,9 @@ fn grep_with_negated_path_constraint() {
|
||||
result.matches.len()
|
||||
);
|
||||
assert!(
|
||||
result.files[0].relative_path.starts_with("tests/"),
|
||||
result.files[0].relative_path().starts_with("tests/"),
|
||||
"should only match tests/ file, got: {}",
|
||||
result.files[0].relative_path
|
||||
result.files[0].relative_path()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1111,6 +1273,9 @@ fn grep_with_negated_text_constraint() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// "tests/helper.rs" contains "test" in path, should be excluded
|
||||
@@ -1122,9 +1287,9 @@ fn grep_with_negated_text_constraint() {
|
||||
);
|
||||
for file in &result.files {
|
||||
assert!(
|
||||
!file.relative_path.contains("test"),
|
||||
!file.relative_path().contains("test"),
|
||||
"should not match files with 'test' in path, got: {}",
|
||||
file.relative_path
|
||||
file.relative_path()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1147,6 +1312,9 @@ fn grep_empty_file_is_skipped() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -1163,6 +1331,9 @@ fn grep_single_line_no_trailing_newline() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -1184,6 +1355,9 @@ fn grep_unicode_content() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert_eq!(result.matches[0].line_number, 2);
|
||||
@@ -1194,6 +1368,9 @@ fn grep_unicode_content() {
|
||||
&parsed2,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(result2.matches.len(), 1);
|
||||
assert_eq!(result2.matches[0].line_number, 3);
|
||||
@@ -1211,6 +1388,9 @@ fn grep_long_line_is_truncated() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -1237,6 +1417,9 @@ fn regex_word_boundary() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1262,6 +1445,9 @@ fn plain_text_question_mark_is_literal() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1286,6 +1472,9 @@ fn plain_text_query_with_question_mark_in_word() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1307,6 +1496,9 @@ fn regex_question_mark_is_quantifier() {
|
||||
&parsed,
|
||||
®ex_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1333,6 +1525,9 @@ fn fuzzy_finds_exact_substring() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1360,6 +1555,9 @@ fn fuzzy_finds_scattered_characters() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -1380,6 +1578,9 @@ fn fuzzy_highlight_offsets_correct() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -1410,6 +1611,9 @@ fn fuzzy_unicode_char_indices() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Should fuzzy match "régulière" (with multi-byte é and è)
|
||||
@@ -1429,6 +1633,9 @@ fn fuzzy_empty_query_returns_empty() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Empty query returns git-modified files, not fuzzy matches
|
||||
@@ -1450,14 +1657,17 @@ fn fuzzy_with_extension_constraint() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Should only search .rs files
|
||||
for file in &result.files {
|
||||
assert!(
|
||||
file.relative_path.ends_with(".rs"),
|
||||
file.relative_path().ends_with(".rs"),
|
||||
"should only match .rs files, got: {}",
|
||||
file.relative_path
|
||||
file.relative_path()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1476,7 +1686,15 @@ fn fuzzy_respects_page_limit() {
|
||||
opts.max_matches_per_file = 50;
|
||||
|
||||
let parsed = parse_grep_query("target");
|
||||
let result = grep_search(&files, &parsed, &opts, &ContentCacheBudget::unlimited());
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&opts,
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// page_limit is a soft minimum: we always finish the current file, so we
|
||||
// get at least page_limit matches (no data loss) and at most
|
||||
@@ -1512,7 +1730,15 @@ fn fuzzy_respects_max_matches_per_file() {
|
||||
opts.max_matches_per_file = 5;
|
||||
|
||||
let parsed = parse_grep_query("match");
|
||||
let result = grep_search(&files, &parsed, &opts, &ContentCacheBudget::unlimited());
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&opts,
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
@@ -1538,6 +1764,9 @@ fn fuzzy_filters_low_quality_matches() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Should only get high-quality matches
|
||||
@@ -1573,6 +1802,9 @@ fn fuzzy_exact_match_always_passes() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1598,6 +1830,9 @@ fn fuzzy_score_is_captured() {
|
||||
&parsed,
|
||||
&fuzzy_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -1625,6 +1860,9 @@ fn fuzzy_score_is_none_in_plain_mode() {
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
@@ -1636,3 +1874,33 @@ fn fuzzy_score_is_none_in_plain_mode() {
|
||||
"fuzzy_score should be None in plain text mode"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: memmem prefilter rejected files where content casing differed
|
||||
/// from the query, even under smart_case. E.g. "vfio-kvm" failed to find
|
||||
/// "VFIO-KVM" because the lowercased finder did a case-sensitive scan.
|
||||
#[test]
|
||||
fn plain_text_smart_case_finds_uppercase_content_with_lowercase_query() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let files = vec![create_file(
|
||||
tmp.path(),
|
||||
"driver.c",
|
||||
"// VFIO-KVM integration\nstatic int init(void) {}\n",
|
||||
)];
|
||||
|
||||
let parsed = parse_grep_query("vfio-kvm");
|
||||
let result = grep_search(
|
||||
&files,
|
||||
&parsed,
|
||||
&plain_opts(),
|
||||
&ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
1,
|
||||
"lowercase query should case-insensitively match 'VFIO-KVM'"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ name = "fff-grep"
|
||||
description = "File grepping logic for fff"
|
||||
license = "MIT"
|
||||
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
|
||||
version = "0.4.2"
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
|
||||
grep-matcher = { workspace = true }
|
||||
memchr = "2.6.3"
|
||||
|
||||
@@ -8,10 +8,12 @@ Only `search_slice` is supported -- no file/reader/mmap search.
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub use crate::{
|
||||
matcher::{LineTerminator, Match, Matcher, NoError},
|
||||
searcher::{Searcher, SearcherBuilder},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
pub mod lines;
|
||||
pub mod matcher;
|
||||
mod searcher;
|
||||
mod sink;
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
A collection of routines for performing operations on lines.
|
||||
*/
|
||||
|
||||
use {
|
||||
bstr::ByteSlice,
|
||||
grep_matcher::{LineTerminator, Match},
|
||||
};
|
||||
use bstr::ByteSlice;
|
||||
|
||||
use crate::matcher::{LineTerminator, Match};
|
||||
|
||||
/// An explicit iterator over lines in a particular slice of bytes.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Matcher trait inspired by ripgrep's `Matcher` just simpler
|
||||
|
||||
/// A byte range representing a match.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Match {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl Match {
|
||||
/// Create a new match from start/end byte offsets.
|
||||
#[inline]
|
||||
pub fn new(start: usize, end: usize) -> Match {
|
||||
debug_assert!(start <= end);
|
||||
Match { start, end }
|
||||
}
|
||||
|
||||
/// Create a zero-width match at `offset`.
|
||||
#[inline]
|
||||
pub fn zero(offset: usize) -> Match {
|
||||
Match {
|
||||
start: offset,
|
||||
end: offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start byte offset.
|
||||
#[inline]
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
|
||||
/// End byte offset (exclusive).
|
||||
#[inline]
|
||||
pub fn end(&self) -> usize {
|
||||
self.end
|
||||
}
|
||||
|
||||
/// Return a copy with a different end offset.
|
||||
#[inline]
|
||||
pub fn with_end(&self, end: usize) -> Match {
|
||||
debug_assert!(self.start <= end);
|
||||
Match { end, ..*self }
|
||||
}
|
||||
|
||||
/// Shift both offsets forward by `amount`.
|
||||
#[inline]
|
||||
pub fn offset(&self, amount: usize) -> Match {
|
||||
Match {
|
||||
start: self.start + amount,
|
||||
end: self.end + amount,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte length of the match.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
/// True if this is a zero-width match.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Index<Match> for [u8] {
|
||||
type Output = [u8];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: Match) -> &[u8] {
|
||||
&self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::IndexMut<Match> for [u8] {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: Match) -> &mut [u8] {
|
||||
&mut self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Index<Match> for str {
|
||||
type Output = str;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: Match) -> &str {
|
||||
&self[index.start..index.end]
|
||||
}
|
||||
}
|
||||
|
||||
/// A line terminator (always a single byte for fff — no CRLF support needed).
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct LineTerminator(u8);
|
||||
|
||||
impl LineTerminator {
|
||||
/// Create a line terminator from a single byte.
|
||||
#[inline]
|
||||
pub fn byte(byte: u8) -> LineTerminator {
|
||||
LineTerminator(byte)
|
||||
}
|
||||
|
||||
/// Return the terminator byte.
|
||||
#[inline]
|
||||
pub fn as_byte(&self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Return the terminator as a single-element byte slice.
|
||||
#[inline]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
std::slice::from_ref(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LineTerminator {
|
||||
#[inline]
|
||||
fn default() -> LineTerminator {
|
||||
LineTerminator(b'\n')
|
||||
}
|
||||
}
|
||||
|
||||
/// An error type for matchers that never produce errors.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct NoError(());
|
||||
|
||||
impl std::error::Error for NoError {}
|
||||
|
||||
impl std::fmt::Display for NoError {
|
||||
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
unreachable!("NoError should never be instantiated")
|
||||
}
|
||||
}
|
||||
|
||||
/// A matcher finds byte-level matches in a haystack.
|
||||
pub trait Matcher {
|
||||
/// The error type (use [`NoError`] for infallible matchers).
|
||||
type Error: std::fmt::Display;
|
||||
|
||||
/// Find the first match at or after `at` in `haystack`.
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error>;
|
||||
|
||||
/// Find the first match in `haystack`.
|
||||
#[inline]
|
||||
fn find(&self, haystack: &[u8]) -> Result<Option<Match>, Self::Error> {
|
||||
self.find_at(haystack, 0)
|
||||
}
|
||||
|
||||
/// The line terminator this matcher guarantees will never appear in a match.
|
||||
/// Return `None` if the matcher can match across lines.
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<LineTerminator> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Matcher> Matcher for &M {
|
||||
type Error = M::Error;
|
||||
|
||||
#[inline]
|
||||
fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, Self::Error> {
|
||||
(*self).find_at(haystack, at)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn find(&self, haystack: &[u8]) -> Result<Option<Match>, Self::Error> {
|
||||
(*self).find(haystack)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn line_terminator(&self) -> Option<LineTerminator> {
|
||||
(*self).line_terminator()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use grep_matcher::{LineMatchKind, Matcher};
|
||||
|
||||
use crate::{
|
||||
lines::{self, LineStep},
|
||||
lines,
|
||||
matcher::Matcher,
|
||||
searcher::{Config, Range, Searcher},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
@@ -52,17 +51,7 @@ impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
}
|
||||
|
||||
pub(crate) fn find(&mut self, slice: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
match self.matcher.find(slice) {
|
||||
Err(err) => Err(S::Error::error_message(err)),
|
||||
Ok(m) => Ok(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn shortest_match(&mut self, slice: &[u8]) -> Result<Option<usize>, S::Error> {
|
||||
match self.matcher.shortest_match(slice) {
|
||||
Err(err) => Err(S::Error::error_message(err)),
|
||||
Ok(m) => Ok(m),
|
||||
}
|
||||
self.matcher.find(slice).map_err(S::Error::error_message)
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self) -> Result<bool, S::Error> {
|
||||
@@ -74,35 +63,8 @@ impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
}
|
||||
|
||||
pub(crate) fn match_by_line(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
if self.is_line_by_line_fast() {
|
||||
self.match_by_line_fast(buf)
|
||||
} else {
|
||||
self.match_by_line_slow(buf)
|
||||
}
|
||||
}
|
||||
|
||||
fn match_by_line_slow(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
|
||||
let range = Range::new(self.pos(), buf.len());
|
||||
let mut stepper =
|
||||
LineStep::new(self.config.line_term.as_byte(), range.start(), range.end());
|
||||
while let Some(line) = stepper.next_match(buf) {
|
||||
let matched = {
|
||||
let slice = lines::without_terminator(&buf[line], self.config.line_term);
|
||||
self.shortest_match(slice)?.is_some()
|
||||
};
|
||||
self.set_pos(line.end());
|
||||
if matched && !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn match_by_line_fast(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
while !buf[self.pos()..].is_empty() {
|
||||
if let Some(line) = self.find_by_line_fast(buf)? {
|
||||
if let Some(line) = self.find_by_line(buf)? {
|
||||
self.set_pos(line.end());
|
||||
if !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
@@ -116,44 +78,27 @@ impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn find_by_line_fast(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
debug_assert!(self.is_line_by_line_fast());
|
||||
|
||||
fn find_by_line(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
let mut pos = self.pos();
|
||||
while !buf[pos..].is_empty() {
|
||||
match self.matcher.find_candidate_line(&buf[pos..]) {
|
||||
Err(err) => return Err(S::Error::error_message(err)),
|
||||
Ok(None) => return Ok(None),
|
||||
Ok(Some(LineMatchKind::Confirmed(i))) => {
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(i).offset(pos),
|
||||
);
|
||||
if line.start() == buf.len() {
|
||||
pos = buf.len();
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(line));
|
||||
}
|
||||
Ok(Some(LineMatchKind::Candidate(i))) => {
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(i).offset(pos),
|
||||
);
|
||||
let slice = lines::without_terminator(&buf[line], self.config.line_term);
|
||||
if self
|
||||
.matcher
|
||||
.is_match(slice)
|
||||
.map_err(S::Error::error_message)?
|
||||
{
|
||||
return Ok(Some(line));
|
||||
}
|
||||
pos = line.end();
|
||||
}
|
||||
let mat = match self
|
||||
.matcher
|
||||
.find(&buf[pos..])
|
||||
.map_err(S::Error::error_message)?
|
||||
{
|
||||
None => return Ok(None),
|
||||
Some(m) => m,
|
||||
};
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(mat.start()).offset(pos),
|
||||
);
|
||||
if line.start() == buf.len() {
|
||||
pos = buf.len();
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(line));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -191,22 +136,4 @@ impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
self.last_line_counted = upto;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_line_by_line_fast(&self) -> bool {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
if let Some(line_term) = self.matcher.line_terminator() {
|
||||
if line_term.as_byte() == b'\x00' {
|
||||
return false;
|
||||
}
|
||||
if line_term == self.config.line_term {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(non_matching) = self.matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.config.line_term.as_byte())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use grep_matcher::Matcher;
|
||||
|
||||
use crate::{
|
||||
lines,
|
||||
matcher::Matcher,
|
||||
searcher::{Config, Range, Searcher, core::Core},
|
||||
sink::Sink,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use grep_matcher::{LineTerminator, Match, Matcher};
|
||||
|
||||
use crate::{
|
||||
matcher::{LineTerminator, Match, Matcher},
|
||||
searcher::glue::{MultiLine, SliceByLine},
|
||||
sink::{Sink, SinkError},
|
||||
};
|
||||
@@ -190,11 +189,6 @@ impl Searcher {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(non_matching) = matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.line_terminator().as_byte())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-mcp"
|
||||
version = "0.4.2"
|
||||
version = "0.5.2"
|
||||
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.4.0" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.4.2" }
|
||||
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.5.1" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.5.2" }
|
||||
mimalloc = { workspace = true }
|
||||
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
|
||||
schemars = "1.0"
|
||||
|
||||
+16
-24
@@ -12,8 +12,6 @@ mod output;
|
||||
mod server;
|
||||
mod update_check;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use clap::Parser;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
@@ -254,16 +252,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
match FrecencyTracker::new(&frecency_db_path, false) {
|
||||
Ok(tracker) => {
|
||||
if let Ok(mut guard) = shared_frecency.write() {
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
|
||||
let _ =
|
||||
FrecencyTracker::spawn_gc(Arc::clone(&shared_frecency), frecency_db_path, false);
|
||||
let _ = shared_frecency.init(tracker);
|
||||
let _ = shared_frecency.spawn_gc(frecency_db_path, false);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to init frecency db: {}", e);
|
||||
@@ -272,22 +266,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Initialize file picker (spawns background scan + watcher)
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
!args.no_warmup, // warmup_mmap_cache
|
||||
FFFMode::Ai,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path,
|
||||
warmup_mmap_cache: !args.no_warmup,
|
||||
mode: FFFMode::Ai,
|
||||
cache_budget: args
|
||||
.max_cached_files
|
||||
.map(fff::ContentCacheBudget::new_for_repo),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to init file picker: {}", e))?;
|
||||
|
||||
// Apply user-configured cache limit after picker creation.
|
||||
if let Some(limit) = args.max_cached_files
|
||||
&& let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
picker.cache_budget = std::sync::Arc::new(fff::ContentCacheBudget::new(limit));
|
||||
}
|
||||
|
||||
if !args.no_update_check {
|
||||
update_check::spawn_update_check();
|
||||
}
|
||||
@@ -296,7 +288,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = FffServer::new(shared_picker.clone(), shared_frecency.clone());
|
||||
|
||||
// Wait for initial scan in background — don't block server startup
|
||||
let picker_clone_for_scan = Arc::clone(&shared_picker);
|
||||
let picker_clone_for_scan = shared_picker.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
|
||||
@@ -11,7 +11,7 @@ use fff::types::FileItem;
|
||||
use crate::cursor::CursorStore;
|
||||
|
||||
/// Frecency score → single-token word. `None` for low-scoring files.
|
||||
fn frecency_word(score: i64) -> Option<&'static str> {
|
||||
fn frecency_word(score: i32) -> Option<&'static str> {
|
||||
if score >= 100 {
|
||||
Some("hot")
|
||||
} else if score >= 50 {
|
||||
@@ -24,7 +24,7 @@ fn frecency_word(score: i64) -> 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: i64) -> String {
|
||||
pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i32) -> String {
|
||||
match (
|
||||
frecency_word(frecency_score),
|
||||
format_git_status_opt(git_status),
|
||||
@@ -81,34 +81,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;
|
||||
@@ -252,10 +237,10 @@ impl GrepFormatter<'_> {
|
||||
let mut content_first_file = "";
|
||||
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();
|
||||
}
|
||||
if content_def_file.is_empty() && fm.is_definition {
|
||||
content_def_file = &fm.file.relative_path;
|
||||
content_def_file = fm.file.relative_path();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,8 +295,8 @@ impl GrepFormatter<'_> {
|
||||
let file = files[m.file_index];
|
||||
let mut match_lines: Vec<String> = Vec::new();
|
||||
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
if file.relative_path() != current_file {
|
||||
current_file = file.relative_path();
|
||||
match_lines.push(current_file.to_string());
|
||||
}
|
||||
|
||||
@@ -362,14 +347,14 @@ impl GrepFormatter<'_> {
|
||||
&& !show_context
|
||||
&& m.is_definition
|
||||
&& !m.context_after.is_empty()
|
||||
&& !def_expanded_files.contains(file.relative_path.as_str())
|
||||
&& !def_expanded_files.contains(file.relative_path())
|
||||
{
|
||||
let expand_limit = if def_expanded_files.is_empty() {
|
||||
MAX_DEF_EXPAND_FIRST
|
||||
} else {
|
||||
MAX_DEF_EXPAND
|
||||
};
|
||||
def_expanded_files.insert(file.relative_path.as_str());
|
||||
def_expanded_files.insert(file.relative_path());
|
||||
let start_line = m.line_number + 1;
|
||||
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
|
||||
if ctx.trim().is_empty() {
|
||||
@@ -419,10 +404,10 @@ fn format_files_with_matches(
|
||||
let mut first_file = "";
|
||||
for fm in &file_map {
|
||||
if first_file.is_empty() {
|
||||
first_file = &fm.file.relative_path;
|
||||
first_file = fm.file.relative_path();
|
||||
}
|
||||
if first_def_file.is_empty() && fm.is_definition {
|
||||
first_def_file = &fm.file.relative_path;
|
||||
first_def_file = fm.file.relative_path();
|
||||
}
|
||||
}
|
||||
let suggest_path = if !first_def_file.is_empty() {
|
||||
@@ -456,7 +441,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(),
|
||||
def_tag,
|
||||
size_tag(fm.file.size)
|
||||
));
|
||||
@@ -526,7 +511,7 @@ fn format_count(
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
let mut order: Vec<&str> = Vec::new();
|
||||
for m in items {
|
||||
let path = files[m.file_index].relative_path.as_str();
|
||||
let path = files[m.file_index].relative_path();
|
||||
let count = counts.entry(path).or_insert_with(|| {
|
||||
order.push(path);
|
||||
0
|
||||
@@ -550,7 +535,7 @@ fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<
|
||||
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()) {
|
||||
file_preview.push(FileMeta {
|
||||
file,
|
||||
line_number: m.line_number,
|
||||
@@ -569,26 +554,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
|
||||
|
||||
@@ -8,6 +8,8 @@ use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff::types::{FileItem, PaginationArgs};
|
||||
@@ -18,9 +20,6 @@ use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::*;
|
||||
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
|
||||
/// Strip common delimiters and lowercase for fuzzy fallback queries.
|
||||
fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
@@ -65,6 +64,7 @@ fn make_grep_options(
|
||||
before_context: ctx_lines,
|
||||
after_context: after_ctx,
|
||||
classify_definitions: true,
|
||||
trim_whitespace: true,
|
||||
},
|
||||
auto_expand,
|
||||
)
|
||||
@@ -76,7 +76,8 @@ pub struct FindFilesParams {
|
||||
pub query: String,
|
||||
/// Max results (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
// this has to be float because llms are stupid
|
||||
pub max_results: Option<f64>,
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
@@ -88,7 +89,7 @@ pub struct GrepParams {
|
||||
pub query: String,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
pub max_results: Option<f64>, // this has to be float because llms are stupid
|
||||
/// Cursor from previous result. Only use if previous results weren't sufficient.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
@@ -150,13 +151,13 @@ pub struct MultiGrepParams {
|
||||
pub constraints: Option<String>,
|
||||
/// Max matching lines (default 20).
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<usize>,
|
||||
pub max_results: Option<f64>,
|
||||
/// Cursor from previous result.
|
||||
pub cursor: Option<String>,
|
||||
/// Output format (default 'content').
|
||||
pub output_mode: Option<String>,
|
||||
/// Context lines before/after each match.
|
||||
pub context: Option<usize>,
|
||||
pub context: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -249,12 +250,9 @@ impl FffServer {
|
||||
.as_ref()
|
||||
.ok_or_else(|| ErrorData::internal_error("File picker not initialized", None))?;
|
||||
|
||||
let files = picker.get_files();
|
||||
let budget = picker.cache_budget();
|
||||
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let parsed = parser.parse(query);
|
||||
let result = grep::grep_search(files, &parsed, &options, budget);
|
||||
let result = picker.grep(&parsed, &options);
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
|
||||
@@ -277,8 +275,7 @@ impl FffServer {
|
||||
};
|
||||
|
||||
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
|
||||
let retry_result =
|
||||
grep::grep_search(files, &rest_parsed, &retry_options, budget);
|
||||
let retry_result = picker.grep(&rest_parsed, &retry_options);
|
||||
|
||||
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
|
||||
let mut cs = self.lock_cursors()?;
|
||||
@@ -306,7 +303,7 @@ impl FffServer {
|
||||
let fuzzy_query = cleanup_fuzzy_query(query);
|
||||
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
|
||||
let fuzzy_parsed = parser.parse(&fuzzy_query);
|
||||
let fuzzy_result = grep::grep_search(files, &fuzzy_parsed, &fuzzy_options, budget);
|
||||
let fuzzy_result = picker.grep(&fuzzy_parsed, &fuzzy_options);
|
||||
|
||||
if !fuzzy_result.matches.is_empty() {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
@@ -317,8 +314,8 @@ impl FffServer {
|
||||
let mut current_file = "";
|
||||
for m in fuzzy_result.matches.iter().take(3) {
|
||||
let file = fuzzy_result.files[m.file_index];
|
||||
if file.relative_path.as_str() != current_file {
|
||||
current_file = &file.relative_path;
|
||||
if file.relative_path() != current_file {
|
||||
current_file = file.relative_path();
|
||||
lines.push(current_file.to_string());
|
||||
}
|
||||
lines.push(format!(" {}: {}", m.line_number, m.line_content));
|
||||
@@ -343,7 +340,8 @@ impl FffServer {
|
||||
limit: 1,
|
||||
},
|
||||
};
|
||||
let file_result = FilePicker::fuzzy_search(files, &file_query, None, file_opts);
|
||||
let file_result =
|
||||
FilePicker::fuzzy_search(picker.get_files(), &file_query, None, file_opts);
|
||||
if let (Some(top), Some(score)) =
|
||||
(file_result.items.first(), file_result.scores.first())
|
||||
{
|
||||
@@ -352,7 +350,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()
|
||||
))]));
|
||||
}
|
||||
}
|
||||
@@ -402,7 +400,7 @@ impl FffServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<FindFilesParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let max_results = params.max_results.unwrap_or(20.0).round() as usize; // safe
|
||||
let query = ¶ms.query;
|
||||
|
||||
let page_offset = params
|
||||
@@ -473,11 +471,14 @@ impl FffServer {
|
||||
|
||||
if page_offset == 0 {
|
||||
if is_exact_match {
|
||||
lines.push(format!("→ Read {} (exact match!)", top_item.relative_path));
|
||||
lines.push(format!(
|
||||
"→ Read {} (exact match!)",
|
||||
top_item.relative_path()
|
||||
));
|
||||
} else if scores.len() < 2 || scores[0].total > scores[1].total.saturating_mul(2) {
|
||||
lines.push(format!(
|
||||
"→ Read {} (best match — Read this file directly)",
|
||||
top_item.relative_path
|
||||
top_item.relative_path()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -492,8 +493,8 @@ impl FffServer {
|
||||
for item in &items {
|
||||
lines.push(format!(
|
||||
"{}{}",
|
||||
item.relative_path,
|
||||
file_suffix(item.git_status, item.total_frecency_score)
|
||||
item.relative_path(),
|
||||
file_suffix(item.git_status, item.total_frecency_score())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -518,7 +519,7 @@ impl FffServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<GrepParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let max_results = params.max_results.unwrap_or(20.0) as usize;
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let parsed = QueryParser::new(AiGrepConfig).parse(¶ms.query);
|
||||
@@ -560,7 +561,8 @@ impl FffServer {
|
||||
|
||||
impl FffServer {
|
||||
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
|
||||
let max_results = params.max_results.unwrap_or(20);
|
||||
let max_results = params.max_results.unwrap_or(20.0).round() as usize;
|
||||
let context = params.context.map(|v| v.round() as usize);
|
||||
let output_mode = OutputMode::new(params.output_mode.as_deref());
|
||||
|
||||
let file_offset = params
|
||||
@@ -569,12 +571,8 @@ impl FffServer {
|
||||
.and_then(|id| self.cursor_store.lock().ok()?.get(id))
|
||||
.unwrap_or(0);
|
||||
|
||||
let (options, auto_expand) = make_grep_options(
|
||||
output_mode,
|
||||
GrepMode::PlainText,
|
||||
file_offset,
|
||||
params.context,
|
||||
);
|
||||
let (options, auto_expand) =
|
||||
make_grep_options(output_mode, GrepMode::PlainText, file_offset, context);
|
||||
|
||||
let ctx_lines = options.before_context;
|
||||
let constraint_query = params.constraints.as_deref().unwrap_or("");
|
||||
@@ -593,13 +591,23 @@ impl FffServer {
|
||||
|
||||
let files = picker.get_files();
|
||||
let budget = picker.cache_budget();
|
||||
let result = grep::multi_grep_search(files, &patterns_refs, constraints, &options, budget);
|
||||
let overlay_guard = picker.bigram_overlay().map(|o| o.read());
|
||||
let result = grep::multi_grep_search(
|
||||
files,
|
||||
&patterns_refs,
|
||||
constraints,
|
||||
&options,
|
||||
budget,
|
||||
picker.bigram_index(),
|
||||
overlay_guard.as_deref(),
|
||||
None,
|
||||
);
|
||||
let file_refs: Vec<&FileItem> = result.files.to_vec();
|
||||
|
||||
if result.matches.is_empty() && file_offset == 0 {
|
||||
// Fallback: try individual patterns with plain grep
|
||||
let (fallback_options, _) =
|
||||
make_grep_options(output_mode, GrepMode::PlainText, 0, params.context);
|
||||
make_grep_options(output_mode, GrepMode::PlainText, 0, context);
|
||||
|
||||
let fallback_options = GrepSearchOptions {
|
||||
time_budget_ms: 3000,
|
||||
@@ -615,7 +623,8 @@ impl FffServer {
|
||||
};
|
||||
|
||||
let parsed = parser.parse(&full_query);
|
||||
let fb_result = grep::grep_search(files, &parsed, &fallback_options, budget);
|
||||
let fb_result =
|
||||
grep::grep_search(files, &parsed, &fallback_options, budget, None, None, None);
|
||||
|
||||
if !fb_result.matches.is_empty() {
|
||||
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-nvim"
|
||||
version = "0.4.2"
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -35,6 +35,22 @@ path = "src/bin/grep_profiler.rs"
|
||||
name = "grep_vs_rg"
|
||||
path = "src/bin/grep_vs_rg.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_grep_query"
|
||||
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"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
@@ -44,8 +60,10 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff = { package = "fff-search", path = "../fff-core" , version = "0.4.0" }
|
||||
fff-query-parser = { path = "../fff-query-parser" , version = "0.4.2" }
|
||||
fff = { package = "fff-search", path = "../fff-core", version = "0.5.1", features = [
|
||||
"mimalloc-collect",
|
||||
] }
|
||||
fff-query-parser = { path = "../fff-query-parser", version = "0.5.2" }
|
||||
|
||||
# External dependencies
|
||||
blake3 = "1.8.2"
|
||||
@@ -76,6 +94,9 @@ name = "indexing_and_search"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
name = "grep_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff::types::{ContentCacheBudget, FileItem};
|
||||
use fff::{BigramFilter, GrepMode, GrepSearchOptions, build_bigram_index, grep};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
struct TestData {
|
||||
files: Vec<FileItem>,
|
||||
bigram: BigramFilter,
|
||||
budget: ContentCacheBudget,
|
||||
}
|
||||
|
||||
static SETUP: OnceLock<TestData> = OnceLock::new();
|
||||
|
||||
fn big_repo_path() -> std::path::PathBuf {
|
||||
if let Some(path) = std::env::var_os("BIG_REPO_PATH") {
|
||||
return std::path::PathBuf::from(path);
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
std::path::PathBuf::from("./big-repo"),
|
||||
std::path::PathBuf::from("../../big-repo"),
|
||||
];
|
||||
for p in &candidates {
|
||||
if p.exists() {
|
||||
return p.clone();
|
||||
}
|
||||
}
|
||||
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 repo = big_repo_path();
|
||||
let canonical = fff::path_utils::canonicalize(&repo).expect("canonicalize");
|
||||
|
||||
eprintln!("Loading files from {:?}...", canonical);
|
||||
let mut files = load_files(&canonical);
|
||||
let budget = ContentCacheBudget::new_for_repo(files.len());
|
||||
|
||||
// Warm the content cache so warm benchmarks hit OnceLock.
|
||||
// Use unlimited budget for warmup — we want ALL files cached.
|
||||
// The repo budget (5k cap for 93k files) would leave most uncached.
|
||||
eprintln!("Warming content cache for {} files...", files.len());
|
||||
{
|
||||
let warmup_budget = ContentCacheBudget::unlimited();
|
||||
let mut buf = Vec::with_capacity(64 * 1024);
|
||||
for f in files.iter() {
|
||||
let _ = f.get_content_for_search(&mut buf, &warmup_budget);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("Building bigram index...");
|
||||
let (bigram, binary_indices) = build_bigram_index(&files, &budget);
|
||||
for &i in &binary_indices {
|
||||
files[i].set_binary(true);
|
||||
}
|
||||
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(
|
||||
"Ready: {} files ({} non-binary), bigram {:.1} MB",
|
||||
files.len(),
|
||||
non_binary,
|
||||
bigram.heap_bytes() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
|
||||
TestData {
|
||||
files,
|
||||
bigram,
|
||||
budget,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzzy_options() -> GrepSearchOptions {
|
||||
GrepSearchOptions {
|
||||
mode: GrepMode::Fuzzy,
|
||||
..plain_options()
|
||||
}
|
||||
}
|
||||
|
||||
fn do_grep(
|
||||
files: &[FileItem],
|
||||
query: &str,
|
||||
options: &GrepSearchOptions,
|
||||
budget: &ContentCacheBudget,
|
||||
bigram: Option<&BigramFilter>,
|
||||
) -> usize {
|
||||
let parsed = grep::parse_grep_query(query);
|
||||
let result = grep::grep_search(
|
||||
black_box(files),
|
||||
black_box(&parsed),
|
||||
black_box(options),
|
||||
budget,
|
||||
bigram,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
result.matches.len()
|
||||
}
|
||||
|
||||
fn bench_plain_warm(c: &mut Criterion) {
|
||||
let test_picker = 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"),
|
||||
("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/"),
|
||||
];
|
||||
|
||||
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 queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter(|| do_grep(&test_picker.files, q, &opts, &test_picker.budget, None))
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_bigram_warm(c: &mut Criterion) {
|
||||
let test_picker = 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"),
|
||||
("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/"),
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("bigram_warm");
|
||||
group.sample_size(30);
|
||||
group.warm_up_time(Duration::from_secs(2));
|
||||
group.measurement_time(Duration::from_secs(5));
|
||||
|
||||
for (name, query) in queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter(|| {
|
||||
do_grep(
|
||||
&test_picker.files,
|
||||
q,
|
||||
&opts,
|
||||
&test_picker.budget,
|
||||
Some(&test_picker.bigram),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_fuzzy_warm(c: &mut Criterion) {
|
||||
let test_picker = setup();
|
||||
let opts = fuzzy_options();
|
||||
|
||||
let 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"),
|
||||
];
|
||||
|
||||
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 queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter(|| do_grep(&test_picker.files, q, &opts, &test_picker.budget, None))
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_fuzzy_bigram_warm(c: &mut Criterion) {
|
||||
let test_picker = setup();
|
||||
let opts = fuzzy_options();
|
||||
|
||||
let 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"),
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("fuzzy_bigram_warm");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(2));
|
||||
group.measurement_time(Duration::from_secs(8));
|
||||
|
||||
for (name, query) in queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter(|| {
|
||||
do_grep(
|
||||
&test_picker.files,
|
||||
q,
|
||||
&opts,
|
||||
&test_picker.budget,
|
||||
Some(&test_picker.bigram),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_plain_cold(c: &mut Criterion) {
|
||||
let test_picker = 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));
|
||||
|
||||
let canonical = fff::path_utils::canonicalize(&big_repo_path()).expect("canonicalize");
|
||||
|
||||
for (name, query) in queries {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(name), query, |b, q| {
|
||||
b.iter_with_setup(
|
||||
|| load_files(&canonical),
|
||||
|fresh_files| do_grep(&fresh_files, q, &opts, &test_picker.budget, None),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_plain_warm,
|
||||
bench_bigram_warm,
|
||||
bench_fuzzy_warm,
|
||||
bench_fuzzy_bigram_warm,
|
||||
bench_plain_cold,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -1,9 +1,11 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::types::{FileItem, PaginationArgs};
|
||||
use fff::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff::types::{ContentCacheBudget, FileItem, PaginationArgs};
|
||||
use fff::{
|
||||
FilePickerOptions, FuzzySearchOptions, GrepMode, GrepSearchOptions, QueryParser,
|
||||
SharedFrecency, SharedPicker, build_bigram_index, grep,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Initialize tracing to output to console
|
||||
@@ -27,11 +29,14 @@ fn init_file_picker_internal(
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), String> {
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(shared_picker),
|
||||
Arc::clone(shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
FilePickerOptions {
|
||||
base_path: path.to_string(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
|
||||
}
|
||||
@@ -133,8 +138,8 @@ 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 = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
init_file_picker_internal(
|
||||
&canonical_path.to_string_lossy(),
|
||||
@@ -179,8 +184,8 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("index_big_repo", |b| {
|
||||
b.iter(|| {
|
||||
let sp: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let sf: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let sp = SharedPicker::default();
|
||||
let sf = SharedFrecency::default();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
|
||||
@@ -235,11 +240,12 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -283,11 +289,12 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: threads,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -329,11 +336,12 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -387,11 +395,12 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(subset),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -431,11 +440,12 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -454,11 +464,12 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -477,11 +488,12 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed_mod),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -499,11 +511,12 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed_mod),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -522,11 +535,12 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -544,11 +558,12 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed_controller),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -588,11 +603,12 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -611,11 +627,12 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -634,11 +651,12 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
@@ -654,6 +672,91 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark grep search with bigram index prefiltering
|
||||
fn bench_grep_search(c: &mut Criterion) {
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping grep benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let budget = ContentCacheBudget::new_for_repo(files.len());
|
||||
|
||||
eprintln!(" Building bigram index for {} files...", files.len());
|
||||
let start = std::time::Instant::now();
|
||||
let (bigram_filter, _overflow_indices) = build_bigram_index(&files, &budget);
|
||||
eprintln!(
|
||||
" Bigram index built in {:.2}s ({} columns)",
|
||||
start.elapsed().as_secs_f64(),
|
||||
bigram_filter.columns_used(),
|
||||
);
|
||||
|
||||
let mut group = c.benchmark_group("grep");
|
||||
group.sample_size(50);
|
||||
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 0,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 100,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
};
|
||||
|
||||
let test_queries = vec![
|
||||
("common", "struct"),
|
||||
("specific", "DEFINE_MUTEX"),
|
||||
("path_filter", "*.h mutex"),
|
||||
];
|
||||
|
||||
let grep_parser = fff::QueryParser::new(fff::GrepConfig);
|
||||
|
||||
for (name, query) in &test_queries {
|
||||
let parsed = grep_parser.parse(query);
|
||||
|
||||
// With bigram index
|
||||
group.bench_with_input(BenchmarkId::new("with_bigram", name), query, |b, _| {
|
||||
b.iter(|| {
|
||||
let result = grep::grep_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
black_box(&options),
|
||||
&budget,
|
||||
Some(&bigram_filter),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
result.matches.len()
|
||||
});
|
||||
});
|
||||
|
||||
// Without bigram index
|
||||
group.bench_with_input(BenchmarkId::new("without_bigram", name), query, |b, _| {
|
||||
b.iter(|| {
|
||||
let result = grep::grep_search(
|
||||
black_box(&files),
|
||||
black_box(&parsed),
|
||||
black_box(&options),
|
||||
&budget,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
result.matches.len()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_indexing,
|
||||
@@ -663,6 +766,7 @@ criterion_group!(
|
||||
bench_search_scalability,
|
||||
bench_search_ordering,
|
||||
bench_pagination_performance,
|
||||
bench_grep_search,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/// Benchmark: AVX2 vs scalar case-insensitive memmem prefilter.
|
||||
///
|
||||
/// Loads all non-binary file contents from a repo, then times both
|
||||
/// implementations scanning every file for the query.
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin bench_ci_memmem
|
||||
/// ./target/release/bench_ci_memmem --path ./big-repo --query "nomore" --iters 5
|
||||
use fff::case_insensitive_memmem;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn fmt_dur(us: u128) -> String {
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", us as f64 / 1_000_000.0)
|
||||
} else if us > 1000 {
|
||||
format!("{:.2}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}µs", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn stats(times_us: &mut [u128]) -> (u128, u128, u128, u128) {
|
||||
times_us.sort();
|
||||
let sum: u128 = times_us.iter().sum();
|
||||
let mean = sum / times_us.len() as u128;
|
||||
let median = times_us[times_us.len() / 2];
|
||||
(mean, median, times_us[0], times_us[times_us.len() - 1])
|
||||
}
|
||||
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
}
|
||||
|
||||
fn load_file_contents(base_path: &Path) -> Vec<Vec<u8>> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut contents = Vec::new();
|
||||
let max_size = 10 * 1024 * 1024u64;
|
||||
|
||||
WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
|
||||
.for_each(|entry| {
|
||||
let path = entry.path();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
if size == 0 || size > max_size || detect_binary(path, size) {
|
||||
return;
|
||||
}
|
||||
if let Ok(data) = std::fs::read(path) {
|
||||
contents.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
contents
|
||||
}
|
||||
|
||||
fn bench_impl(
|
||||
label: &str,
|
||||
contents: &[Vec<u8>],
|
||||
needle_lower: &[u8],
|
||||
total_bytes: u64,
|
||||
iters: usize,
|
||||
search_fn: fn(&[u8], &[u8]) -> bool,
|
||||
) {
|
||||
eprintln!("\n [{}]", label);
|
||||
let mut times = Vec::with_capacity(iters);
|
||||
let mut hit_count = 0u32;
|
||||
|
||||
for i in 0..iters {
|
||||
let t = Instant::now();
|
||||
let mut hits = 0u32;
|
||||
for content in contents {
|
||||
if search_fn(content, needle_lower) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
let us = t.elapsed().as_micros();
|
||||
times.push(us);
|
||||
hit_count = hits;
|
||||
let tp = total_bytes as f64 / (us as f64 / 1_000_000.0) / (1024.0 * 1024.0 * 1024.0);
|
||||
eprintln!(
|
||||
" iter {}: {} ({} hits, {:.2} GB/s)",
|
||||
i + 1,
|
||||
fmt_dur(us),
|
||||
hits,
|
||||
tp
|
||||
);
|
||||
}
|
||||
|
||||
let (mean, median, min, max) = stats(&mut times);
|
||||
let med_tp = total_bytes as f64 / (median as f64 / 1_000_000.0) / (1024.0 * 1024.0 * 1024.0);
|
||||
eprintln!(
|
||||
" mean: {} median: {} ({:.2} GB/s) min: {} max: {} hits: {}",
|
||||
fmt_dur(mean),
|
||||
fmt_dur(median),
|
||||
med_tp,
|
||||
fmt_dur(min),
|
||||
fmt_dur(max),
|
||||
hit_count
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let path = args
|
||||
.iter()
|
||||
.position(|a| a == "--path")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(".");
|
||||
|
||||
let query = args
|
||||
.iter()
|
||||
.position(|a| a == "--query")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("TODO");
|
||||
|
||||
let iters: usize = args
|
||||
.iter()
|
||||
.position(|a| a == "--iters")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let repo = std::path::PathBuf::from(path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Path not found: {}", path);
|
||||
eprintln!("Usage: bench_ci_memmem --path <dir> --query <text> [--iters N]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
|
||||
let needle_lower: Vec<u8> = query.bytes().map(|b| b.to_ascii_lowercase()).collect();
|
||||
|
||||
eprintln!("=== bench_ci_memmem: AVX2 vs Scalar ===");
|
||||
eprintln!("Path: {}", canonical.display());
|
||||
eprintln!("Query: \"{}\"", query);
|
||||
eprintln!("Needle: {:?}", std::str::from_utf8(&needle_lower).unwrap());
|
||||
eprintln!("Iters: {}", iters);
|
||||
|
||||
eprint!("\n[1/2] Loading files into memory... ");
|
||||
let t = Instant::now();
|
||||
let contents = load_file_contents(&canonical);
|
||||
let total_bytes: u64 = contents.iter().map(|c| c.len() as u64).sum();
|
||||
eprintln!(
|
||||
"{} files, {:.1} MB in {:.2}s",
|
||||
contents.len(),
|
||||
total_bytes as f64 / (1024.0 * 1024.0),
|
||||
t.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
eprintln!("\n[2/2] Benchmarking memmem prefilter (scanning ALL files)");
|
||||
|
||||
bench_impl(
|
||||
"Packed pair: (AVX2 two-byte scan)",
|
||||
&contents,
|
||||
&needle_lower,
|
||||
total_bytes,
|
||||
iters,
|
||||
case_insensitive_memmem::search_packed_pair,
|
||||
);
|
||||
|
||||
bench_impl(
|
||||
"scalar: memchr2 first-byte + AVX2 verify",
|
||||
&contents,
|
||||
&needle_lower,
|
||||
total_bytes,
|
||||
iters,
|
||||
case_insensitive_memmem::search,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/// Single-query grep benchmark with bigram index profiling.
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin bench_grep_query
|
||||
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "MAX_FILE_SIZE" --iters 3
|
||||
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "TODO" --no-bigram
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use fff::types::ContentCacheBudget;
|
||||
use std::time::Instant;
|
||||
|
||||
fn fmt_dur(us: u128) -> String {
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", us as f64 / 1_000_000.0)
|
||||
} else if us > 1000 {
|
||||
format!("{:.2}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}µs", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_grep(files: &[fff::FileItem], index: Option<&fff::BigramFilter>, query: &str, iters: usize) {
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: usize::MAX,
|
||||
mode: GrepMode::PlainText,
|
||||
time_budget_ms: 0,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let budget = ContentCacheBudget::default();
|
||||
let mut times_us = Vec::with_capacity(iters);
|
||||
|
||||
for i in 0..iters {
|
||||
let t = Instant::now();
|
||||
let result = grep_search(files, &parsed, &options, &budget, index, None, None);
|
||||
let us = t.elapsed().as_micros();
|
||||
times_us.push(us);
|
||||
|
||||
eprintln!(
|
||||
" iter {}: {} ({} matches in {} files, {}/{} searched)",
|
||||
i + 1,
|
||||
fmt_dur(us),
|
||||
result.matches.len(),
|
||||
result.files_with_matches,
|
||||
result.total_files_searched,
|
||||
result.total_files,
|
||||
);
|
||||
}
|
||||
|
||||
if times_us.len() > 1 {
|
||||
times_us.sort();
|
||||
let sum: u128 = times_us.iter().sum();
|
||||
let mean = sum / times_us.len() as u128;
|
||||
let median = times_us[times_us.len() / 2];
|
||||
let min = times_us[0];
|
||||
let max = times_us[times_us.len() - 1];
|
||||
eprintln!(
|
||||
" mean: {} median: {} min: {} max: {}",
|
||||
fmt_dur(mean),
|
||||
fmt_dur(median),
|
||||
fmt_dur(min),
|
||||
fmt_dur(max)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bigram(files: &mut [fff::FileItem]) -> fff::BigramFilter {
|
||||
let budget = ContentCacheBudget::default();
|
||||
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
|
||||
|
||||
for &i in &binary_indices {
|
||||
files[i].set_binary(true);
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let path = args
|
||||
.iter()
|
||||
.position(|a| a == "--path")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(".");
|
||||
|
||||
let query = args
|
||||
.iter()
|
||||
.position(|a| a == "--query")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("TODO");
|
||||
|
||||
let iters: usize = args
|
||||
.iter()
|
||||
.position(|a| a == "--iters")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let no_bigram = args.iter().any(|a| a == "--no-bigram");
|
||||
|
||||
let repo = std::path::PathBuf::from(path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Path not found: {}", path);
|
||||
eprintln!("Usage: bench_grep_query --path <dir> --query <text> [--iters N] [--no-bigram]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
|
||||
eprintln!("=== bench_grep_query ===");
|
||||
eprintln!("Path: {}", canonical.display());
|
||||
eprintln!("Query: \"{}\"", query);
|
||||
eprintln!("Iters: {}", iters);
|
||||
eprintln!();
|
||||
|
||||
// ── 1. Scan files ──────────────────────────────────────────────────
|
||||
eprint!("[1/3] Scanning files... ");
|
||||
let t = Instant::now();
|
||||
let mut files = fff::scan_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(
|
||||
"{} files in {:.2}s ({} non-binary)",
|
||||
files.len(),
|
||||
t.elapsed().as_secs_f64(),
|
||||
non_binary,
|
||||
);
|
||||
|
||||
if no_bigram {
|
||||
eprintln!("[2/3] Bigram index skipped (--no-bigram)");
|
||||
eprintln!(
|
||||
"\n[3/3] Running grep \"{}\" x {} iterations\n",
|
||||
query, iters
|
||||
);
|
||||
run_grep(&files, None, query, iters);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Build bigram index ──────────────────────────────────────────
|
||||
eprint!("[2/3] Bigram index... ");
|
||||
let t = Instant::now();
|
||||
let index = build_bigram(&mut files);
|
||||
eprintln!(
|
||||
"done in {:.2}s ({} cols, {:.1} MB)",
|
||||
t.elapsed().as_secs_f64(),
|
||||
index.columns_used(),
|
||||
index.heap_bytes() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
|
||||
// ── 3. Grep ───────────────────────────────────────────────────────
|
||||
eprintln!(
|
||||
"\n[3/3] Running grep \"{}\" x {} iterations\n",
|
||||
query, iters
|
||||
);
|
||||
run_grep(&files, Some(&index), query, iters);
|
||||
}
|
||||
@@ -35,12 +35,17 @@ fn main() {
|
||||
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
|
||||
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
0,
|
||||
None,
|
||||
@@ -64,7 +69,6 @@ fn main() {
|
||||
("long_rare", "user_authentication", 100),
|
||||
("typo_resistant", "contrlr", 200),
|
||||
("path_like", "src/lib", 150),
|
||||
("single_char", "a", 300),
|
||||
("two_char", "st", 300),
|
||||
("partial_word", "test", 200),
|
||||
("deep_path", "drivers/net", 100),
|
||||
|
||||
@@ -31,14 +31,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
@@ -74,11 +79,20 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, &parsed, &options, &fff::ContentCacheBudget::zero());
|
||||
let result = grep_search(
|
||||
files,
|
||||
&parsed,
|
||||
&options,
|
||||
&fff::ContentCacheBudget::default(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
eprintln!("══════════════════════════════════════════════════════════════");
|
||||
@@ -102,7 +116,7 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
if m.file_index != current_file_idx {
|
||||
current_file_idx = m.file_index;
|
||||
let file = &result.files[m.file_index];
|
||||
eprintln!("\n ┌─ {}", file.relative_path);
|
||||
eprintln!("\n ┌─ {}", file.relative_path());
|
||||
}
|
||||
|
||||
// Truncate long lines for display
|
||||
@@ -156,10 +170,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);
|
||||
@@ -173,7 +205,7 @@ fn main() {
|
||||
eprintln!("Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(
|
||||
"Loaded {} files ({} non-binary) in {:.2}s\n",
|
||||
files.len(),
|
||||
@@ -181,7 +213,7 @@ 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");
|
||||
@@ -192,7 +224,7 @@ fn main() {
|
||||
);
|
||||
} else {
|
||||
// Run user-provided queries
|
||||
for query in &args {
|
||||
for query in &queries {
|
||||
run_fuzzy_query(&files, query, "user query");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use fff::FileItem;
|
||||
/// Live grep benchmark profiler for fff.nvim
|
||||
///
|
||||
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
|
||||
@@ -10,7 +9,11 @@ use fff::FileItem;
|
||||
/// Usage:
|
||||
/// cargo build --release --bin grep_profiler
|
||||
/// ./target/release/grep_profiler [--path /path/to/repo]
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use fff::{
|
||||
BigramFilter, FileItem,
|
||||
grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query},
|
||||
types::ContentCacheBudget,
|
||||
};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -34,14 +37,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
@@ -115,6 +123,7 @@ impl BenchStats {
|
||||
struct GrepBench<'a> {
|
||||
files: &'a [FileItem],
|
||||
options: GrepSearchOptions,
|
||||
bigram_index: Option<&'a BigramFilter>,
|
||||
}
|
||||
|
||||
impl<'a> GrepBench<'a> {
|
||||
@@ -125,6 +134,7 @@ impl<'a> GrepBench<'a> {
|
||||
fn with_mode(files: &'a [FileItem], mode: GrepMode) -> Self {
|
||||
Self {
|
||||
files,
|
||||
bigram_index: None,
|
||||
options: GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
@@ -136,10 +146,16 @@ impl<'a> GrepBench<'a> {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn with_bigram(mut self, index: &'a BigramFilter) -> Self {
|
||||
self.bigram_index = Some(index);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run a single grep search, return (duration, match_count, files_searched)
|
||||
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
|
||||
let parsed = parse_grep_query(query);
|
||||
@@ -148,7 +164,10 @@ impl<'a> GrepBench<'a> {
|
||||
self.files,
|
||||
&parsed,
|
||||
&self.options,
|
||||
&fff::ContentCacheBudget::default(),
|
||||
&ContentCacheBudget::default(),
|
||||
self.bigram_index,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
(elapsed, result.matches.len(), result.total_files_searched)
|
||||
@@ -171,6 +190,17 @@ impl<'a> GrepBench<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bigram(files: &mut [FileItem]) -> BigramFilter {
|
||||
let budget = ContentCacheBudget::default();
|
||||
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
|
||||
|
||||
for &i in &binary_indices {
|
||||
files[i].set_binary(true);
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
fn fmt_dur(d: Duration) -> String {
|
||||
let us = d.as_micros();
|
||||
if us > 1_000_000 {
|
||||
@@ -234,9 +264,9 @@ fn main() {
|
||||
// Direct file loading (no background thread)
|
||||
eprintln!("\n[1/7] Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let mut files = load_files(&canonical);
|
||||
let load_time = load_start.elapsed();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
let large_files = files.iter().filter(|f| f.size > 10 * 1024 * 1024).count();
|
||||
eprintln!(
|
||||
" Loaded {} files in {:.2}s ({} non-binary, {} >10MB skipped)\n",
|
||||
@@ -305,6 +335,26 @@ 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);
|
||||
}
|
||||
|
||||
// ── Fuzzy grep benchmarks ─────────────────────────────────────────────
|
||||
eprintln!("\n[4/7] Fuzzy grep warm benchmarks");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
@@ -337,6 +387,19 @@ fn main() {
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy + bigram prefilter benchmarks ─────────────────────────────
|
||||
eprintln!("\n[4b/7] Fuzzy grep with bigram prefilter");
|
||||
print_header();
|
||||
|
||||
let fuzzy_bigram_bench =
|
||||
GrepBench::with_mode(&files, GrepMode::Fuzzy).with_bigram(&bigram_index);
|
||||
|
||||
for (name, query, iters) in &fuzzy_queries {
|
||||
let bg_name = format!("bg_{}", name);
|
||||
let (stats, matches, files_searched) = fuzzy_bigram_bench.bench_query(query, *iters);
|
||||
print_row(&bg_name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
// ── Fuzzy incremental typing ────────────────────────────────────────
|
||||
eprintln!("\n[5/7] Fuzzy incremental typing simulation");
|
||||
eprintln!(" Simulates user typing character by character (fuzzy mode).\n");
|
||||
@@ -385,6 +448,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 typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
@@ -455,6 +519,7 @@ fn main() {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(
|
||||
@@ -462,6 +527,9 @@ fn main() {
|
||||
&parsed,
|
||||
&opts,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
@@ -483,7 +551,11 @@ fn main() {
|
||||
eprintln!("\n=== Summary ===");
|
||||
let mmap_count = files
|
||||
.iter()
|
||||
.filter(|f| f.get_mmap(&fff::ContentCacheBudget::unlimited()).is_some())
|
||||
.filter(|f| {
|
||||
let mut buf = Vec::new();
|
||||
f.get_content_for_search(&mut buf, &fff::ContentCacheBudget::unlimited())
|
||||
.is_some()
|
||||
})
|
||||
.count();
|
||||
eprintln!(" Files with cached mmap: {}", mmap_count);
|
||||
eprintln!(" Total indexed files: {}", files.len());
|
||||
|
||||
@@ -48,14 +48,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
let relative_start = (path_string.len() - relative_path.len()) as u16;
|
||||
let filename_start = path_string
|
||||
.rfind('/')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(relative_start as usize) as u16;
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
path_string,
|
||||
relative_start,
|
||||
filename_start,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
@@ -208,9 +213,18 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, &parsed, &options, &fff::ContentCacheBudget::zero());
|
||||
let result = grep_search(
|
||||
files,
|
||||
&parsed,
|
||||
&options,
|
||||
&fff::ContentCacheBudget::zero(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
@@ -228,6 +242,7 @@ fn benchmark_fff_smart_case(files: &[FileItem], parsed: &FFFQuery<'_>) -> (usize
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(
|
||||
@@ -235,6 +250,9 @@ fn benchmark_fff_smart_case(files: &[FileItem], parsed: &FFFQuery<'_>) -> (usize
|
||||
parsed,
|
||||
&options,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
@@ -254,6 +272,7 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: false,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(
|
||||
@@ -261,6 +280,9 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
&parsed,
|
||||
&options,
|
||||
&fff::ContentCacheBudget::unlimited(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
(result.matches.len(), elapsed)
|
||||
@@ -351,7 +373,7 @@ fn main() {
|
||||
|
||||
eprintln!("[1/5] Indexing files...");
|
||||
let files = load_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
|
||||
eprintln!(" {} files ({} searchable)\n", files.len(), non_binary);
|
||||
|
||||
eprintln!("[2/5] Warming caches (fff mmap + OS page cache)...");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -179,17 +178,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Initialize FilePicker
|
||||
println!("Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// Wait for initial scan
|
||||
|
||||
@@ -2,49 +2,22 @@ use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::{
|
||||
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut iteration = 0;
|
||||
if !shared_picker.wait_for_scan(timeout) {
|
||||
return Err(format!("Scan timed out after {} seconds", timeout_secs));
|
||||
}
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let is_scanning = picker.is_scan_active();
|
||||
let file_count = picker.get_files().len();
|
||||
|
||||
if iteration % 20 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] Scanning: {}, Files: {}",
|
||||
start.elapsed().as_secs_f64(),
|
||||
is_scanning,
|
||||
file_count
|
||||
);
|
||||
}
|
||||
|
||||
if !is_scanning && file_count > 0 {
|
||||
return Ok(file_count);
|
||||
}
|
||||
} else if iteration % 20 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] FilePicker is None",
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
|
||||
if start.elapsed() > timeout {
|
||||
return Err(format!("Scan timed out after {} seconds", timeout_secs));
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|e| format!("Failed to acquire read lock: {}", e))?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().len())
|
||||
} else {
|
||||
Err("FilePicker not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +25,7 @@ fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usiz
|
||||
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
.map_err(|e| format!("Failed to acquire read lock: {}", e))?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().to_vec())
|
||||
} else {
|
||||
@@ -74,18 +47,21 @@ fn main() {
|
||||
fff::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: canonical_path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("Failed to init FilePicker");
|
||||
.expect("Failed to init FilePicker with shared state");
|
||||
|
||||
// Give background thread time to start
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
@@ -98,16 +74,16 @@ fn main() {
|
||||
|
||||
// Test queries representing different search patterns
|
||||
let test_queries = vec![
|
||||
("short_common", "mod", 5000),
|
||||
("medium_specific", "controller", 2000),
|
||||
("long_rare", "user_authentication", 1000),
|
||||
("typo_resistant", "contrlr", 2000),
|
||||
("path_like", "src/lib", 1500),
|
||||
("single_char", "a", 3000),
|
||||
("two_char", "st", 3000),
|
||||
("partial_word", "test", 2000),
|
||||
("deep_path", "drivers/net", 1000),
|
||||
("extension", ".rs", 2000),
|
||||
("short_common", "mod", 100),
|
||||
("medium_specific", "controller", 100),
|
||||
("long_rare", "user_authentication", 100),
|
||||
("typo_resistant", "contrlr", 100),
|
||||
("path_like", "src/lib", 100),
|
||||
("single_char", "a", 100),
|
||||
("two_char", "st", 100),
|
||||
("partial_word", "test", 100),
|
||||
("deep_path", "drivers/net", 100),
|
||||
("extension", ".rs", 100),
|
||||
];
|
||||
|
||||
eprintln!("Running search profiler...");
|
||||
|
||||
@@ -2,7 +2,6 @@ use fff::file_picker::{FFFMode, FilePicker};
|
||||
use fff::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -79,17 +78,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Initialize the file picker
|
||||
println!("📁 Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// Wait for initial scan to complete
|
||||
@@ -142,7 +144,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !files.is_empty() {
|
||||
println!("Sample files:");
|
||||
for (i, file) in files.iter().take(5).enumerate() {
|
||||
println!(" {}. {}", i + 1, file.relative_path);
|
||||
println!(" {}. {}", i + 1, file.relative_path());
|
||||
}
|
||||
}
|
||||
files.len()
|
||||
|
||||
@@ -7,8 +7,8 @@ use fff::git::format_git_status;
|
||||
use fff::{FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -25,11 +25,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let r = running.clone();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
// Clone for signal handler
|
||||
let picker_for_cleanup = Arc::clone(&shared_picker);
|
||||
let picker_for_cleanup = shared_picker.clone();
|
||||
ctrlc::set_handler(move || {
|
||||
println!("\n🛑 Received interrupt signal, shutting down...");
|
||||
if let Ok(mut guard) = picker_for_cleanup.write() {
|
||||
@@ -46,11 +46,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Initialize the file picker using shared state
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
FFFMode::default(),
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: base_path.clone(),
|
||||
warmup_mmap_cache: false,
|
||||
mode: FFFMode::default(),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// Get initial file count from shared state
|
||||
@@ -65,7 +68,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(
|
||||
" {}. {} ({})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
file.relative_path(),
|
||||
format_git_status(file.git_status)
|
||||
);
|
||||
}
|
||||
@@ -107,7 +110,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let newest_files = files.iter().rev().take(added.min(3));
|
||||
for file in newest_files {
|
||||
println!(" ➕ {}", file.relative_path);
|
||||
println!(" ➕ {}", file.relative_path());
|
||||
}
|
||||
} else {
|
||||
let removed = last_count - current_count;
|
||||
@@ -183,7 +186,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(
|
||||
" {}. {} (score: {})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
file.relative_path(),
|
||||
score.total
|
||||
);
|
||||
}
|
||||
@@ -198,5 +201,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,14 +24,3 @@ impl<T> IntoLuaResult<T> for Result<T, CoreError> {
|
||||
self.map_err(to_lua_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for Result<T, PoisonError> to convert to Result<T, CoreError>
|
||||
pub trait IntoCoreError<T> {
|
||||
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError>;
|
||||
}
|
||||
|
||||
impl<T, G> IntoCoreError<T> for Result<T, std::sync::PoisonError<G>> {
|
||||
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError> {
|
||||
self.map_err(|_| err)
|
||||
}
|
||||
}
|
||||
|
||||
+79
-142
@@ -1,19 +1,19 @@
|
||||
use crate::path_shortening::shorten_path_with_cache;
|
||||
use error::{IntoCoreError, IntoLuaResult};
|
||||
use error::IntoLuaResult;
|
||||
use fff::file_picker::FilePicker;
|
||||
use fff::frecency::FrecencyTracker;
|
||||
use fff::path_utils::expand_tilde;
|
||||
use fff::query_tracker::QueryTracker;
|
||||
use fff::{
|
||||
DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, PaginationArgs,
|
||||
QueryParser, Score, SearchResult, SharedFrecency, SharedPicker, SharedQueryTracker,
|
||||
DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, GrepConfig,
|
||||
PaginationArgs, QueryParser, Score, SearchResult, SharedFrecency, SharedPicker,
|
||||
SharedQueryTracker,
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use mlua::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use path_shortening::PathShortenStrategy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
mod error;
|
||||
@@ -27,18 +27,15 @@ static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
// the global state for neovim lives here for efficiency
|
||||
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
|
||||
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(SharedPicker::default);
|
||||
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(SharedFrecency::default);
|
||||
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(SharedQueryTracker::default);
|
||||
|
||||
pub fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let mut frecency = FRECENCY.write().into_lua_result()?;
|
||||
if frecency.is_some() {
|
||||
*frecency = None;
|
||||
}
|
||||
@@ -48,12 +45,9 @@ pub fn init_db(
|
||||
drop(frecency);
|
||||
|
||||
// Spawn background GC to purge stale entries without blocking startup
|
||||
let _ = FrecencyTracker::spawn_gc(Arc::clone(&FRECENCY), frecency_db_path, use_unsafe_no_lock);
|
||||
let _ = FRECENCY.spawn_gc(frecency_db_path, use_unsafe_no_lock);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let mut query_tracker = QUERY_TRACKER.write().into_lua_result()?;
|
||||
if query_tracker.is_some() {
|
||||
*query_tracker = None;
|
||||
}
|
||||
@@ -66,40 +60,30 @@ pub fn init_db(
|
||||
}
|
||||
|
||||
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.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()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.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> {
|
||||
{
|
||||
let guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let guard = FILE_PICKER.read().into_lua_result()?;
|
||||
if guard.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
FILE_PICKER.clone(),
|
||||
FRECENCY.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path,
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.into_lua_result()?;
|
||||
|
||||
@@ -111,9 +95,7 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
// a window where FILE_PICKER is None (which causes FilePickerMissing
|
||||
// errors if the UI is searching concurrently).
|
||||
{
|
||||
let mut guard = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
let mut guard = FILE_PICKER.write()?;
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
@@ -126,11 +108,14 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string_lossy().to_string(),
|
||||
false,
|
||||
FFFMode::Neovim,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
FILE_PICKER.clone(),
|
||||
FRECENCY.clone(),
|
||||
fff::FilePickerOptions {
|
||||
base_path: path.to_string_lossy().to_string(),
|
||||
warmup_mmap_cache: true,
|
||||
mode: FFFMode::Neovim,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@@ -172,10 +157,7 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -207,10 +189,7 @@ pub fn fuzzy_search_files(
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
@@ -218,10 +197,7 @@ pub fn fuzzy_search_files(
|
||||
let base_path = picker.base_path();
|
||||
let min_combo_count = min_combo_count.unwrap_or(3);
|
||||
|
||||
let query_tracker_guard = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let query_tracker_guard = QUERY_TRACKER.read().into_lua_result()?;
|
||||
|
||||
if query_tracker_guard.as_ref().is_none() {
|
||||
tracing::warn!("Query tracker not initialized");
|
||||
@@ -265,7 +241,7 @@ 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.path.as_path().cmp(&path)) {
|
||||
if let Ok(idx) = files.binary_search_by(|f| f.as_path().cmp(&path)) {
|
||||
let found = SearchResult {
|
||||
items: vec![&files[idx]],
|
||||
scores: vec![Score {
|
||||
@@ -300,6 +276,7 @@ pub fn live_grep(
|
||||
smart_case,
|
||||
grep_mode,
|
||||
time_budget_ms,
|
||||
trim_whitespace,
|
||||
): (
|
||||
String,
|
||||
Option<usize>,
|
||||
@@ -309,18 +286,15 @@ pub fn live_grep(
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
Option<u64>,
|
||||
Option<bool>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let parsed = fff::grep::parse_grep_query(&query);
|
||||
|
||||
let mode = match grep_mode.as_deref() {
|
||||
Some("regex") => fff::GrepMode::Regex,
|
||||
Some("fuzzy") => fff::GrepMode::Fuzzy,
|
||||
@@ -338,11 +312,10 @@ pub fn live_grep(
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
classify_definitions: false,
|
||||
trim_whitespace: trim_whitespace.unwrap_or(false),
|
||||
};
|
||||
|
||||
let result =
|
||||
fff::grep::grep_search(picker.get_files(), &parsed, &options, picker.cache_budget());
|
||||
|
||||
let result = picker.grep(&parsed, &options);
|
||||
lua_types::GrepResultLua::from(result).into_lua(lua)
|
||||
}
|
||||
|
||||
@@ -363,9 +336,9 @@ fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaRe
|
||||
item.set("name", name.as_str())?;
|
||||
item.set("size", path.metadata().map(|m| m.len()).unwrap_or(0))?;
|
||||
item.set("modified", 0u64)?;
|
||||
item.set("access_frecency_score", 0i64)?;
|
||||
item.set("modification_frecency_score", 0i64)?;
|
||||
item.set("total_frecency_score", 0i64)?;
|
||||
item.set("access_frecency_score", 0i32)?;
|
||||
item.set("modification_frecency_score", 0i32)?;
|
||||
item.set("total_frecency_score", 0i32)?;
|
||||
item.set("git_status", "")?;
|
||||
item.set("is_binary", false)?;
|
||||
|
||||
@@ -401,10 +374,7 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
|
||||
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
|
||||
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let frecency_guard = FRECENCY.read().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -414,18 +384,12 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
drop(frecency_guard);
|
||||
|
||||
// Quick lock to update single file's frecency score in picker
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let frecency_guard = FRECENCY.read().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -437,10 +401,7 @@ pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -454,10 +415,7 @@ pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
|
||||
}
|
||||
|
||||
pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -466,10 +424,7 @@ pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -478,22 +433,16 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
|
||||
FILE_PICKER.refresh_git_status(&FRECENCY).into_lua_result()
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let frecency_guard = FRECENCY.read().into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
@@ -505,10 +454,7 @@ pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
@@ -519,10 +465,7 @@ pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let mut file_picker = FILE_PICKER.write().into_lua_result()?;
|
||||
if let Some(picker) = file_picker.take() {
|
||||
drop(picker);
|
||||
::tracing::info!("FilePicker cleanup completed");
|
||||
@@ -540,10 +483,7 @@ pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
|
||||
// Get the project path before spawning thread
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -560,9 +500,10 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
let query_tracker = QUERY_TRACKER.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
if let Ok(mut guard) = query_tracker.write()
|
||||
&& let Some(tracker) = guard.as_mut()
|
||||
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -579,20 +520,14 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
|
||||
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let query_tracker = QUERY_TRACKER.read().into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -604,19 +539,17 @@ pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>>
|
||||
|
||||
pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
let query_tracker = QUERY_TRACKER.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
if let Ok(mut guard) = query_tracker.write()
|
||||
&& let Some(ref mut tracker) = *guard
|
||||
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -632,20 +565,14 @@ pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
|
||||
|
||||
pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let query_tracker = QUERY_TRACKER.read().into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -655,16 +582,25 @@ 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()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let file_picker = FILE_PICKER.read().into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
@@ -898,6 +834,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)
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ impl IntoLua for LuaPosition {
|
||||
|
||||
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("path", item.path.to_string_lossy().to_string())?;
|
||||
table.set("relative_path", item.relative_path.clone())?;
|
||||
table.set("name", item.file_name.clone())?;
|
||||
table.set("path", item.path_str())?;
|
||||
table.set("relative_path", item.relative_path())?;
|
||||
table.set("name", item.file_name())?;
|
||||
table.set("size", item.size)?;
|
||||
table.set("modified", item.modified)?;
|
||||
table.set("access_frecency_score", item.access_frecency_score)?;
|
||||
@@ -45,9 +45,9 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
"modification_frecency_score",
|
||||
item.modification_frecency_score,
|
||||
)?;
|
||||
table.set("total_frecency_score", item.total_frecency_score)?;
|
||||
table.set("total_frecency_score", item.total_frecency_score())?;
|
||||
table.set("git_status", format_git_status(item.git_status))?;
|
||||
table.set("is_binary", item.is_binary)?;
|
||||
table.set("is_binary", item.is_binary())?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
@@ -61,6 +61,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))
|
||||
@@ -122,14 +123,14 @@ impl IntoLua for GrepResultLua<'_> {
|
||||
|
||||
// File metadata from the deduplicated files vec
|
||||
let file = self.inner.files[m.file_index];
|
||||
item.set("path", file.path.to_string_lossy().to_string())?;
|
||||
item.set("relative_path", file.relative_path.as_str())?;
|
||||
item.set("name", file.file_name.as_str())?;
|
||||
item.set("is_binary", file.is_binary)?;
|
||||
item.set("path", file.path_str())?;
|
||||
item.set("relative_path", file.relative_path())?;
|
||||
item.set("name", file.file_name())?;
|
||||
item.set("is_binary", file.is_binary())?;
|
||||
item.set("git_status", format_git_status(file.git_status))?;
|
||||
item.set("size", file.size)?;
|
||||
item.set("modified", file.modified)?;
|
||||
item.set("total_frecency_score", file.total_frecency_score)?;
|
||||
item.set("total_frecency_score", file.total_frecency_score())?;
|
||||
item.set("access_frecency_score", file.access_frecency_score)?;
|
||||
item.set(
|
||||
"modification_frecency_score",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fff-query-parser"
|
||||
version = "0.4.2"
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
description = "Query parser for fff file finder - includes specific syntax for various constraints like globs, extensions, regex etc"
|
||||
license = "MIT"
|
||||
|
||||
@@ -834,8 +834,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI grep config tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_detects_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
@@ -1035,8 +1033,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── File picker filename constraint tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_bare_filename_constraint() {
|
||||
let parser = QueryParser::new(FileSearchConfig);
|
||||
|
||||
+10
-4
@@ -1,4 +1,5 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 19
|
||||
*fff.nvim.txt*
|
||||
For Neovim >= 0.10.0 Last change: 2026 April 15
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -6,7 +7,7 @@ Table of Contents *fff.nvim-table-of-contents*
|
||||
- MCP |fff.nvim-mcp|
|
||||
- Neovim guide |fff.nvim-neovim-guide|
|
||||
1. Links |fff.nvim-links|
|
||||
FFFAI agents (MCP) | Neovim usersA fast file search for your AI and neovim, with memory built-in
|
||||
FFFAI agents (MCP) | Neovim usersA fast file search for your AI and neovim, with memory built-in
|
||||
|
||||
|
||||
|
||||
@@ -121,8 +122,12 @@ VIM.PACK
|
||||
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,
|
||||
@@ -296,6 +301,7 @@ all available options:
|
||||
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
|
||||
},
|
||||
})
|
||||
<
|
||||
|
||||
@@ -54,6 +54,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
|
||||
@@ -332,6 +333,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)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -88,11 +88,11 @@ M.ensure_initialized = function()
|
||||
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
|
||||
|
||||
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
|
||||
if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end
|
||||
if not ok then vim.notify('Failed to databases: ' .. tostring(result), vim.log.levels.WARN) end
|
||||
|
||||
ok, result = pcall(fuzzy.init_file_picker, config.base_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
|
||||
vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR)
|
||||
return fuzzy
|
||||
end
|
||||
|
||||
|
||||
+24
-15
@@ -1,19 +1,10 @@
|
||||
local M = {}
|
||||
local system = require('fff.utils.system')
|
||||
local fs_utils = require('fff.utils.fs')
|
||||
local fff_version = require('fff.utils.version')
|
||||
|
||||
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
|
||||
|
||||
local function get_current_version(plugin_dir, callback)
|
||||
vim.system({ 'git', 'rev-parse', '--short', 'HEAD' }, { cwd = plugin_dir }, function(result)
|
||||
if result.code ~= 0 or not result.stdout or result.stdout == '' then
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
callback(result.stdout:gsub('%s+', ''))
|
||||
end)
|
||||
end
|
||||
|
||||
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
|
||||
|
||||
local function get_binary_path(plugin_dir)
|
||||
@@ -168,20 +159,38 @@ function M.ensure_downloaded(opts, callback)
|
||||
return
|
||||
end
|
||||
|
||||
local function on_version(target_version)
|
||||
if not target_version then
|
||||
local function on_release_tag(release_tag)
|
||||
if not release_tag then
|
||||
callback(false, 'Could not determine target version')
|
||||
return
|
||||
end
|
||||
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
download_from_github(target_version, binary_path, opts, callback)
|
||||
download_from_github(release_tag, binary_path, opts, callback)
|
||||
end
|
||||
|
||||
if opts.version then
|
||||
on_version(opts.version)
|
||||
on_release_tag(opts.version)
|
||||
else
|
||||
get_current_version(plugin_dir, on_version)
|
||||
-- plugin_dir is <repo>/lua; parent is the repo root
|
||||
local repo_root = vim.fn.fnamemodify(plugin_dir, ':h')
|
||||
|
||||
-- 1. Try reading the CI-created tag on HEAD (no version computation)
|
||||
local tag = fff_version.current_release_tag(repo_root)
|
||||
if tag then
|
||||
on_release_tag(tag)
|
||||
return
|
||||
end
|
||||
|
||||
-- 2. No local tag — construct the nightly version (bumps patch so
|
||||
-- the prerelease is higher than Cargo.toml base in semver)
|
||||
local info, err = fff_version.resolve(repo_root)
|
||||
if info then
|
||||
on_release_tag(info.release_tag)
|
||||
return
|
||||
end
|
||||
|
||||
callback(false, err or 'Could not determine target version')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function M.scan_files()
|
||||
|
||||
local ok, result = pcall(fuzzy.scan_files)
|
||||
if not ok then
|
||||
vim.notify('Failed to trigger file scan: ' .. result, vim.log.levels.ERROR)
|
||||
vim.notify('Failed to trigger file scan: ' .. tostring(result), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -121,7 +121,7 @@ function M.track_access(file_path)
|
||||
if not M.state.initialized then return end
|
||||
|
||||
local ok, result = pcall(fuzzy.track_access, file_path)
|
||||
if not ok then vim.notify('Failed to record file access: ' .. result, vim.log.levels.WARN) end
|
||||
if not ok then vim.notify('Failed to record file access: ' .. tostring(result), vim.log.levels.WARN) end
|
||||
end
|
||||
|
||||
--- Get file content for preview
|
||||
@@ -156,7 +156,7 @@ function M.get_scan_progress()
|
||||
|
||||
local ok, result = pcall(fuzzy.get_scan_progress)
|
||||
if not ok then
|
||||
vim.notify('Failed to get scan progress: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to get scan progress: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return { scanned_files_count = 0, is_scanning = false }
|
||||
end
|
||||
|
||||
@@ -170,7 +170,7 @@ function M.refresh_git_status()
|
||||
|
||||
local ok, result = pcall(fuzzy.refresh_git_status)
|
||||
if not ok then
|
||||
vim.notify('Failed to refresh git status: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to refresh git status: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return {}
|
||||
end
|
||||
|
||||
@@ -185,7 +185,7 @@ function M.stop_background_monitor()
|
||||
|
||||
local ok, result = pcall(fuzzy.stop_background_monitor)
|
||||
if not ok then
|
||||
vim.notify('Failed to stop background monitor: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to stop background monitor: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return false
|
||||
end
|
||||
return result
|
||||
@@ -199,7 +199,7 @@ function M.wait_for_initial_scan(timeout_ms)
|
||||
|
||||
local ok, result = pcall(fuzzy.wait_for_initial_scan, timeout_ms)
|
||||
if not ok then
|
||||
vim.notify('Failed to wait for initial scan: ' .. result, vim.log.levels.WARN)
|
||||
vim.notify('Failed to wait for initial scan: ' .. tostring(result), vim.log.levels.WARN)
|
||||
return false
|
||||
end
|
||||
return result
|
||||
|
||||
@@ -6,6 +6,28 @@ local rust = require('fff.rust')
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Preview buffers are scratch buffers. Detect the file's language and attach
|
||||
-- highlighting directly, but keep buffer filetype empty to avoid ftplugin and
|
||||
-- LSP side effects that are meant for real editing buffers.
|
||||
local function attach_preview_highlighter(bufnr, filetype)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
pcall(vim.treesitter.stop, bufnr)
|
||||
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
||||
|
||||
if not filetype or filetype == '' then return end
|
||||
|
||||
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, filetype)
|
||||
if not lang_ok or not lang then lang = filetype end
|
||||
|
||||
if pcall(vim.treesitter.language.add, lang) then
|
||||
pcall(vim.treesitter.start, bufnr, lang)
|
||||
else
|
||||
vim.api.nvim_set_option_value('syntax', filetype, { buf = bufnr })
|
||||
end
|
||||
end
|
||||
|
||||
local function set_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
@@ -274,7 +296,7 @@ local function link_buffer_content(source_bufnr, target_bufnr)
|
||||
set_buffer_lines(target_bufnr, lines)
|
||||
|
||||
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
|
||||
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
|
||||
if source_ft ~= '' then attach_preview_highlighter(target_bufnr, source_ft) end
|
||||
|
||||
M.state.has_more_content = false
|
||||
M.state.total_file_lines = #lines
|
||||
@@ -532,7 +554,7 @@ function M.preview_file(file_path, bufnr)
|
||||
set_buffer_lines(bufnr, content)
|
||||
|
||||
local file_config = M.get_file_config(file_path)
|
||||
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
|
||||
attach_preview_highlighter(bufnr, info.filetype)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
@@ -651,7 +673,7 @@ function M.preview_binary_file(file_path, bufnr)
|
||||
end
|
||||
|
||||
set_buffer_lines(bufnr, lines)
|
||||
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
|
||||
attach_preview_highlighter(bufnr, 'text')
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
|
||||
@@ -838,11 +860,8 @@ function M.clear_buffer(bufnr)
|
||||
cleanup_file_operation()
|
||||
M.clear_preview_visual_state(bufnr)
|
||||
|
||||
pcall(vim.treesitter.stop, bufnr)
|
||||
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
||||
attach_preview_highlighter(bufnr, '')
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
|
||||
set_buffer_lines(bufnr, {})
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+30
-1
@@ -36,7 +36,6 @@ function M.live_grep(opts)
|
||||
mode = 'grep',
|
||||
renderer = grep_renderer,
|
||||
grep_config = grep_config,
|
||||
query = '',
|
||||
}, opts or {})
|
||||
|
||||
picker_ui.open(picker_opts)
|
||||
@@ -56,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()
|
||||
|
||||
+21
-1
@@ -442,6 +442,16 @@ end
|
||||
local preview_config = conf.get().preview
|
||||
if preview_config then preview.setup(preview_config) end
|
||||
|
||||
local function suspend_paste()
|
||||
if not vim.o.paste then return false end
|
||||
vim.o.paste = false
|
||||
return true
|
||||
end
|
||||
|
||||
local function restore_paste(should_restore)
|
||||
if should_restore then vim.o.paste = true end
|
||||
end
|
||||
|
||||
M.state = {
|
||||
active = false,
|
||||
layout = nil,
|
||||
@@ -491,6 +501,7 @@ M.state = {
|
||||
ns_id = nil,
|
||||
|
||||
last_status_info = nil,
|
||||
restore_paste = false,
|
||||
|
||||
last_preview_file = nil,
|
||||
last_preview_location = nil, -- Track last preview location to detect changes
|
||||
@@ -528,6 +539,9 @@ function M.create_ui()
|
||||
local config = M.state.config
|
||||
if not config then return false end
|
||||
|
||||
-- Prompt editing should behave consistently even if the user has :set paste.
|
||||
M.state.restore_paste = suspend_paste()
|
||||
|
||||
if not M.state.ns_id then
|
||||
M.state.ns_id = vim.api.nvim_create_namespace('fff_picker_status')
|
||||
combo_renderer.init(M.state.ns_id)
|
||||
@@ -1338,8 +1352,11 @@ local function shrink_path(path, max_width)
|
||||
end
|
||||
|
||||
local function format_file_display(item, max_width)
|
||||
-- vim.json.decode may return Blobs for strings with NUL bytes; coerce to string.
|
||||
local filename = item.name
|
||||
if type(filename) ~= 'string' then filename = filename and tostring(filename) or '' end
|
||||
local dir_path = item.directory or ''
|
||||
if type(dir_path) ~= 'string' then dir_path = dir_path and tostring(dir_path) or '' end
|
||||
|
||||
if dir_path == '' and item.relative_path then
|
||||
local parent_dir = vim.fn.fnamemodify(item.relative_path, ':h')
|
||||
@@ -2268,7 +2285,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
|
||||
@@ -2408,6 +2425,8 @@ function M.close()
|
||||
vim.cmd('stopinsert')
|
||||
M.state.active = false
|
||||
|
||||
restore_paste(M.state.restore_paste)
|
||||
|
||||
combo_renderer.cleanup()
|
||||
scrollbar.cleanup()
|
||||
|
||||
@@ -2475,6 +2494,7 @@ function M.close()
|
||||
M.state.grep_regex_fallback_error = nil
|
||||
M.state.suggestion_items = nil
|
||||
M.state.suggestion_source = nil
|
||||
M.state.restore_paste = false
|
||||
M.state.combo_visible = true
|
||||
M.state.combo_initial_cursor = nil
|
||||
M.reset_history_state()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
local M = {}
|
||||
|
||||
local is_windows = (package.config:sub(1, 1) == '\\')
|
||||
|
||||
--- Shell-quote a string for safe interpolation into a command.
|
||||
---@param s string
|
||||
---@return string
|
||||
local function shell_quote(s)
|
||||
if is_windows then return '"' .. s:gsub('"', '\\"') .. '"' end
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
--- Run a git command in the given repository and return trimmed stdout.
|
||||
---@param repo_root string
|
||||
---@return string|nil output trimmed stdout, or nil on failure
|
||||
local function git(repo_root, ...)
|
||||
local parts = { 'git', '-C', shell_quote(repo_root) }
|
||||
for i = 1, select('#', ...) do
|
||||
parts[#parts + 1] = shell_quote(select(i, ...))
|
||||
end
|
||||
|
||||
local redirect = is_windows and ' 2>NUL' or ' 2>/dev/null'
|
||||
local handle = io.popen(table.concat(parts, ' ') .. redirect)
|
||||
if not handle then return nil end
|
||||
|
||||
local output = handle:read('*a')
|
||||
handle:close()
|
||||
|
||||
if not output or output:match('^%s*$') then return nil end
|
||||
return output:gsub('%s+$', '')
|
||||
end
|
||||
|
||||
function M.current_release_tag(repo_root)
|
||||
local raw = git(repo_root, 'tag', '--points-at', 'HEAD')
|
||||
if not raw then return nil end
|
||||
|
||||
local stable, nightly, dev, other
|
||||
for tag in raw:gmatch('[^\n]+') do
|
||||
if tag:match('^v%d') then
|
||||
stable = tag
|
||||
elseif tag:match('%-nightly%.') then
|
||||
nightly = tag
|
||||
elseif tag:match('%-dev%.') then
|
||||
dev = tag
|
||||
else
|
||||
other = tag
|
||||
end
|
||||
end
|
||||
|
||||
return stable or nightly or dev or other
|
||||
end
|
||||
|
||||
function M.read_base_version(repo_root)
|
||||
local cargo_path = repo_root .. '/crates/fff-core/Cargo.toml'
|
||||
local f = io.open(cargo_path, 'r')
|
||||
if not f then return nil end
|
||||
|
||||
for line in f:lines() do
|
||||
local ver = line:match('^version%s*=%s*"([^"]+)"')
|
||||
if ver then
|
||||
f:close()
|
||||
return ver
|
||||
end
|
||||
end
|
||||
|
||||
f:close()
|
||||
return nil
|
||||
end
|
||||
|
||||
---@class FFFVersionInfo
|
||||
---@field version string semver version (e.g. "0.4.0" or "0.4.1-nightly.abc1234")
|
||||
---@field release_tag string GitHub release tag for download URLs
|
||||
---@field is_release boolean true for tagged stable releases
|
||||
---@field npm_tag string "latest"|"nightly"|"dev"
|
||||
|
||||
--- Bump the patch component of a semver string.
|
||||
--- "1.2.3" → "1.2.4"
|
||||
---@param version string
|
||||
---@return string|nil bumped version, or nil if parsing fails
|
||||
local function bump_patch(version)
|
||||
local major, minor, patch = version:match('^(%d+)%.(%d+)%.(%d+)')
|
||||
if not major then return nil end
|
||||
return string.format('%s.%s.%d', major, minor, tonumber(patch) + 1)
|
||||
end
|
||||
|
||||
--- Compute the version for a new release based on git state.
|
||||
--- Used by CI to determine what tag to create — NOT for downloads.
|
||||
---
|
||||
--- For prerelease versions the patch is bumped so that the result is
|
||||
--- higher than the current Cargo.toml version in semver ordering.
|
||||
--- This is required for `cargo set-version` / crates.io publishing
|
||||
--- (0.4.1-nightly.x > 0.4.0, whereas 0.4.0-nightly.x < 0.4.0).
|
||||
---
|
||||
--- tagged release (v*) → version from tag, npm_tag = "latest"
|
||||
--- main branch → {base+1}-nightly.{sha}, npm_tag = "nightly"
|
||||
--- detached HEAD → {base+1}-nightly.{sha}, npm_tag = "nightly"
|
||||
--- other branch (PR / feature) → {base+1}-dev.{sha}, npm_tag = "dev"
|
||||
---
|
||||
---@param repo_root string absolute path to the repository root
|
||||
---@return FFFVersionInfo|nil info
|
||||
---@return string|nil err
|
||||
function M.resolve(repo_root)
|
||||
local tag = git(repo_root, 'describe', '--exact-match', '--tags', '--match', 'v*', 'HEAD')
|
||||
|
||||
if tag and tag:match('^v%d') then
|
||||
return {
|
||||
version = tag:sub(2),
|
||||
release_tag = tag,
|
||||
is_release = true,
|
||||
npm_tag = 'latest',
|
||||
}
|
||||
end
|
||||
|
||||
local short_sha = git(repo_root, 'rev-parse', '--short', 'HEAD')
|
||||
if not short_sha then return nil, 'Failed to determine git SHA' end
|
||||
|
||||
local base_version = M.read_base_version(repo_root)
|
||||
if not base_version then return nil, 'Could not read base version from crates/fff-core/Cargo.toml' end
|
||||
|
||||
local next_version = bump_patch(base_version)
|
||||
if not next_version then return nil, 'Could not parse base version: ' .. base_version end
|
||||
|
||||
local branch = git(repo_root, 'symbolic-ref', '--short', 'HEAD')
|
||||
|
||||
local prerelease_label, npm_tag
|
||||
if not branch then
|
||||
prerelease_label = 'nightly'
|
||||
npm_tag = 'nightly'
|
||||
elseif branch == 'main' or branch == 'fix/download-version' then
|
||||
prerelease_label = 'nightly'
|
||||
npm_tag = 'nightly'
|
||||
else
|
||||
prerelease_label = 'dev'
|
||||
npm_tag = 'dev'
|
||||
end
|
||||
|
||||
local version = string.format('%s-%s.%s', next_version, prerelease_label, short_sha)
|
||||
return {
|
||||
version = version,
|
||||
release_tag = version,
|
||||
is_release = false,
|
||||
npm_tag = npm_tag,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
Binary file not shown.
+38
-29
@@ -16,6 +16,7 @@ import type {
|
||||
GrepResult,
|
||||
Location,
|
||||
Result,
|
||||
ScanProgress,
|
||||
Score,
|
||||
SearchResult,
|
||||
} from "./types";
|
||||
@@ -125,6 +126,10 @@ const ffiDefinition = {
|
||||
args: [FFIType.ptr, FFIType.u64],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_wait_for_watcher: {
|
||||
args: [FFIType.ptr, FFIType.u64],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_restart_index: {
|
||||
args: [FFIType.ptr, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
@@ -241,9 +246,7 @@ function snakeToCamel(obj: unknown): unknown {
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
|
||||
letter.toUpperCase(),
|
||||
);
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
result[camelKey] = snakeToCamel(value);
|
||||
}
|
||||
return result;
|
||||
@@ -253,16 +256,18 @@ function snakeToCamel(obj: unknown): unknown {
|
||||
// FffResult byte offsets (must match #[repr(C)] layout on 64-bit)
|
||||
// { success: bool(1+7pad), error: *char(8), handle: *void(8), int_value: i64(8) }
|
||||
// ---------------------------------------------------------------------------
|
||||
const RES_SUCCESS = 0; // bool (1 + 7 padding)
|
||||
const RES_ERROR = 8; // *mut c_char (8)
|
||||
const RES_HANDLE = 16; // *mut c_void (8)
|
||||
const RES_INT_VALUE = 24; // i64 (8)
|
||||
const RES_SUCCESS = 0; // bool (1 + 7 padding)
|
||||
const RES_ERROR = 8; // *mut c_char (8)
|
||||
const RES_HANDLE = 16; // *mut c_void (8)
|
||||
const RES_INT_VALUE = 24; // i64 (8)
|
||||
|
||||
/**
|
||||
* Read the FffResult envelope: check success, extract payload, free envelope.
|
||||
* On error returns a Result<never>. On success returns the raw handle pointer and int_value.
|
||||
*/
|
||||
function readResultEnvelope(resultPtr: Pointer | null): { success: true; handlePtr: number; intValue: number } | Result<never> {
|
||||
function readResultEnvelope(
|
||||
resultPtr: Pointer | null,
|
||||
): { success: true; handlePtr: number; intValue: number } | Result<never> {
|
||||
if (resultPtr === null) {
|
||||
return err("FFI returned null pointer");
|
||||
}
|
||||
@@ -651,16 +656,10 @@ function readGrepMatchStruct(p: number): GrepMatch {
|
||||
match.fuzzyScore = read.u16(pp, GM_FUZZY_SCORE);
|
||||
}
|
||||
if (ctxBeforeCount > 0) {
|
||||
match.contextBefore = readCStringArray(
|
||||
read.ptr(pp, GM_CTX_BEFORE),
|
||||
ctxBeforeCount,
|
||||
);
|
||||
match.contextBefore = readCStringArray(read.ptr(pp, GM_CTX_BEFORE), ctxBeforeCount);
|
||||
}
|
||||
if (ctxAfterCount > 0) {
|
||||
match.contextAfter = readCStringArray(
|
||||
read.ptr(pp, GM_CTX_AFTER),
|
||||
ctxAfterCount,
|
||||
);
|
||||
match.contextAfter = readCStringArray(read.ptr(pp, GM_CTX_AFTER), ctxAfterCount);
|
||||
}
|
||||
|
||||
return match;
|
||||
@@ -823,14 +822,16 @@ export function ffiIsScanning(handle: NativeHandle): boolean {
|
||||
return library.symbols.fff_is_scanning(handle) as boolean;
|
||||
}
|
||||
|
||||
// FffScanProgress { scanned_files_count: u64(8), is_scanning: bool(1+7pad) }
|
||||
const SP_COUNT = 0; // u64 (8)
|
||||
const SP_SCANNING = 8; // bool (1 + 7 pad)
|
||||
// FffScanProgress { scanned_files_count: u64(8), is_scanning: bool(1), is_watcher_ready: bool(1), is_warmup_complete: bool(1) + pad }
|
||||
const SP_COUNT = 0; // u64 (8)
|
||||
const SP_SCANNING = 8; // bool (1)
|
||||
const SP_WATCHER_READY = 9; // bool (1)
|
||||
const SP_WARMUP_COMPLETE = 10; // bool (1)
|
||||
|
||||
/**
|
||||
* Get scan progress.
|
||||
*/
|
||||
export function ffiGetScanProgress(handle: NativeHandle): Result<{ scannedFilesCount: number; isScanning: boolean }> {
|
||||
export function ffiGetScanProgress(handle: NativeHandle): Result<ScanProgress> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_get_scan_progress(handle);
|
||||
const envelope = readResultEnvelope(resultPtr);
|
||||
@@ -841,9 +842,11 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<{ scannedFilesC
|
||||
}
|
||||
|
||||
const hp = asPtr(envelope.handlePtr);
|
||||
const result = {
|
||||
const result: ScanProgress = {
|
||||
scannedFilesCount: Number(read.u64(hp, SP_COUNT)),
|
||||
isScanning: read.u8(hp, SP_SCANNING) !== 0,
|
||||
isWatcherReady: read.u8(hp, SP_WATCHER_READY) !== 0,
|
||||
isWarmupComplete: read.u8(hp, SP_WARMUP_COMPLETE) !== 0,
|
||||
};
|
||||
library.symbols.fff_free_scan_progress(hp);
|
||||
return { ok: true, value: result };
|
||||
@@ -852,22 +855,28 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<{ scannedFilesC
|
||||
/**
|
||||
* Wait for scan to complete.
|
||||
*/
|
||||
export function ffiWaitForScan(
|
||||
handle: NativeHandle,
|
||||
timeoutMs: number,
|
||||
): Result<boolean> {
|
||||
export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs));
|
||||
return parseBoolResult(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the background file watcher to be ready.
|
||||
*/
|
||||
export function ffiWaitForWatcher(
|
||||
handle: NativeHandle,
|
||||
timeoutMs: number,
|
||||
): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_wait_for_watcher(handle, BigInt(timeoutMs));
|
||||
return parseBoolResult(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart index in new path.
|
||||
*/
|
||||
export function ffiRestartIndex(
|
||||
handle: NativeHandle,
|
||||
newPath: string,
|
||||
): Result<void> {
|
||||
export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath)));
|
||||
return parseVoidResult(resultPtr);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ffiSearch,
|
||||
ffiTrackQuery,
|
||||
ffiWaitForScan,
|
||||
ffiWaitForWatcher,
|
||||
isAvailable,
|
||||
type NativeHandle,
|
||||
} from "./ffi";
|
||||
@@ -345,6 +346,22 @@ export class FileFinder {
|
||||
return ffiWaitForScan(guard.value, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the background file watcher to be ready.
|
||||
*
|
||||
* The watcher is created after the initial scan, git status, and optional
|
||||
* warmup phases complete. Useful for tests that need to ensure filesystem
|
||||
* events will be detected.
|
||||
*
|
||||
* @param timeoutMs - Maximum time to wait in milliseconds (default: 10000)
|
||||
* @returns true if watcher is ready, false if timed out
|
||||
*/
|
||||
waitForWatcher(timeoutMs: number = 10000): Result<boolean> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiWaitForWatcher(guard.value, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the indexed directory to a new path.
|
||||
*
|
||||
@@ -430,4 +447,3 @@ export class FileFinder {
|
||||
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ describe.skipIf(process.platform === "win32")("Git lifecycle integration", () =>
|
||||
let tmpDir: string;
|
||||
let finder: FileFinder;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
// Create temp directory and initialise a git repo with two committed files.
|
||||
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
|
||||
// that git2's resolved workdir paths match the file picker's base_path.
|
||||
@@ -151,6 +151,20 @@ describe.skipIf(process.platform === "win32")("Git lifecycle integration", () =>
|
||||
// Wait for the initial scan to finish
|
||||
const scanResult = finder.waitForScan(10_000);
|
||||
expect(scanResult.ok).toBe(true);
|
||||
|
||||
// Poll getScanProgress until the watcher is ready so that
|
||||
// filesystem events (file creates, deletes) are detected.
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok && progress.value.isWatcherReady) break;
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
const progress = finder.getScanProgress();
|
||||
expect(progress.ok).toBe(true);
|
||||
if (progress.ok) {
|
||||
expect(progress.value.isWatcherReady).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -295,6 +309,48 @@ describe.skipIf(process.platform === "win32")("Git lifecycle integration", () =>
|
||||
expect(result.value.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test("file in a newly created directory is discoverable", async () => {
|
||||
// Create a brand-new directory that didn't exist during the initial scan,
|
||||
// then add a file inside it. The watcher must dynamically pick up the new
|
||||
// directory and index the file.
|
||||
mkdirSync(join(tmpDir, "lib"));
|
||||
writeFileSync(
|
||||
join(tmpDir, "lib", "helpers.ts"),
|
||||
"export function add(a: number, b: number) { return a + b; }\n",
|
||||
);
|
||||
|
||||
const helpers = await waitForFile(finder, "helpers.ts");
|
||||
expect(helpers).toBeDefined();
|
||||
expect(helpers?.relativePath).toBe("lib/helpers.ts");
|
||||
});
|
||||
|
||||
test("files in gitignored directories are not indexed", async () => {
|
||||
// Commit a .gitignore rule first so it's established repo state before
|
||||
// the ignored directory is created. This tests the watch-level filtering
|
||||
// (is_path_ignored in the debouncer callback), not a rescan triggered
|
||||
// by a .gitignore change.
|
||||
writeFileSync(join(tmpDir, ".gitignore"), "build_output/\n");
|
||||
git(tmpDir, "add", ".gitignore");
|
||||
git(tmpDir, "commit", "-m", "add gitignore");
|
||||
|
||||
// Wait for the watcher to settle after the commit.
|
||||
await waitForFile(finder, ".gitignore");
|
||||
|
||||
// Now create the ignored directory and add a file inside it.
|
||||
mkdirSync(join(tmpDir, "build_output"));
|
||||
writeFileSync(join(tmpDir, "build_output", "artifact.bin"), "should not appear\n");
|
||||
|
||||
// Create a non-ignored file as a synchronisation barrier — once it's
|
||||
// indexed, the watcher has processed the same batch of events.
|
||||
writeFileSync(join(tmpDir, "canary.txt"), "visible\n");
|
||||
const canary = await waitForFile(finder, "canary.txt");
|
||||
expect(canary).toBeDefined();
|
||||
|
||||
// The ignored file must NOT appear in the index.
|
||||
const artifact = findFile(finder, "artifact.bin");
|
||||
expect(artifact).toBeUndefined();
|
||||
});
|
||||
|
||||
test("full add-commit cycle for subdirectory file", async () => {
|
||||
git(tmpDir, "add", "src/utils.rs");
|
||||
|
||||
|
||||
@@ -144,6 +144,10 @@ export interface ScanProgress {
|
||||
scannedFilesCount: number;
|
||||
/** Whether a scan is currently in progress */
|
||||
isScanning: boolean;
|
||||
/** Whether the background file watcher is ready */
|
||||
isWatcherReady: boolean;
|
||||
/** Whether the warmup/bigram phase has completed */
|
||||
isWarmupComplete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,4 +364,3 @@ export interface MultiGrepOptions {
|
||||
/** Number of context lines to include after each match (default: 0) */
|
||||
afterContext?: number;
|
||||
}
|
||||
|
||||
|
||||
+168
-87
@@ -45,7 +45,15 @@ import {
|
||||
wrapPointer,
|
||||
} from "ffi-rs";
|
||||
import { findBinary } from "./binary.js";
|
||||
import type { FileItem, GrepMatch, GrepResult, Location, Result, Score, SearchResult } from "./types.js";
|
||||
import type {
|
||||
FileItem,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
Location,
|
||||
Result,
|
||||
Score,
|
||||
SearchResult,
|
||||
} from "./types.js";
|
||||
import { createGrepCursor, err } from "./types.js";
|
||||
|
||||
const LIBRARY_KEY = "fff_c";
|
||||
@@ -216,7 +224,11 @@ function readResultEnvelope(
|
||||
}
|
||||
|
||||
/** Call a function returning FffResult with void payload. */
|
||||
function callVoidResult(funcName: string, paramsType: DataType[], paramsValue: unknown[]): Result<void> {
|
||||
function callVoidResult(
|
||||
funcName: string,
|
||||
paramsType: DataType[],
|
||||
paramsValue: unknown[],
|
||||
): Result<void> {
|
||||
const res = readResultEnvelope(funcName, paramsType, paramsValue);
|
||||
if ("ok" in res) return res;
|
||||
freeResult(res.rawPtr);
|
||||
@@ -224,7 +236,11 @@ function callVoidResult(funcName: string, paramsType: DataType[], paramsValue: u
|
||||
}
|
||||
|
||||
/** Call a function returning FffResult with int_value payload. */
|
||||
function callIntResult(funcName: string, paramsType: DataType[], paramsValue: unknown[]): Result<number> {
|
||||
function callIntResult(
|
||||
funcName: string,
|
||||
paramsType: DataType[],
|
||||
paramsValue: unknown[],
|
||||
): Result<number> {
|
||||
const res = readResultEnvelope(funcName, paramsType, paramsValue);
|
||||
if ("ok" in res) return res;
|
||||
const value = Number(res.struct.int_value);
|
||||
@@ -233,7 +249,11 @@ function callIntResult(funcName: string, paramsType: DataType[], paramsValue: un
|
||||
}
|
||||
|
||||
/** Call a function returning FffResult with bool in int_value. */
|
||||
function callBoolResult(funcName: string, paramsType: DataType[], paramsValue: unknown[]): Result<boolean> {
|
||||
function callBoolResult(
|
||||
funcName: string,
|
||||
paramsType: DataType[],
|
||||
paramsValue: unknown[],
|
||||
): Result<boolean> {
|
||||
const res = readResultEnvelope(funcName, paramsType, paramsValue);
|
||||
if ("ok" in res) return res;
|
||||
const value = Number(res.struct.int_value) !== 0;
|
||||
@@ -242,7 +262,11 @@ function callBoolResult(funcName: string, paramsType: DataType[], paramsValue: u
|
||||
}
|
||||
|
||||
/** Call a function returning FffResult with a C string in handle. */
|
||||
function callStringResult(funcName: string, paramsType: DataType[], paramsValue: unknown[]): Result<string | null> {
|
||||
function callStringResult(
|
||||
funcName: string,
|
||||
paramsType: DataType[],
|
||||
paramsValue: unknown[],
|
||||
): Result<string | null> {
|
||||
const res = readResultEnvelope(funcName, paramsType, paramsValue);
|
||||
if ("ok" in res) return res;
|
||||
const handlePtr = res.struct.handle;
|
||||
@@ -254,7 +278,11 @@ function callStringResult(funcName: string, paramsType: DataType[], paramsValue:
|
||||
}
|
||||
|
||||
/** Call a function returning FffResult with a JSON string in handle. */
|
||||
function callJsonResult<T>(funcName: string, paramsType: DataType[], paramsValue: unknown[]): Result<T> {
|
||||
function callJsonResult<T>(
|
||||
funcName: string,
|
||||
paramsType: DataType[],
|
||||
paramsValue: unknown[],
|
||||
): Result<T> {
|
||||
const res = readResultEnvelope(funcName, paramsType, paramsValue);
|
||||
if ("ok" in res) return res;
|
||||
const handlePtr = res.struct.handle;
|
||||
@@ -306,9 +334,9 @@ export function ffiCreate(
|
||||
const { rawPtr, struct: structData } = callRaw(
|
||||
"fff_create_instance",
|
||||
[
|
||||
DataType.String, // base_path
|
||||
DataType.String, // frecency_db_path
|
||||
DataType.String, // history_db_path
|
||||
DataType.String, // base_path
|
||||
DataType.String, // frecency_db_path
|
||||
DataType.String, // history_db_path
|
||||
DataType.Boolean, // use_unsafe_no_lock
|
||||
DataType.Boolean, // warmup_mmap_cache
|
||||
DataType.Boolean, // ai_mode
|
||||
@@ -522,30 +550,30 @@ interface FffMatchRangeRaw {
|
||||
|
||||
function readFileItemFromRaw(raw: FffFileItemRaw): FileItem {
|
||||
return {
|
||||
path: readCString(raw.path) ?? "",
|
||||
relativePath: readCString(raw.relative_path) ?? "",
|
||||
fileName: readCString(raw.file_name) ?? "",
|
||||
gitStatus: readCString(raw.git_status) ?? "",
|
||||
size: Number(raw.size),
|
||||
modified: Number(raw.modified),
|
||||
accessFrecencyScore: Number(raw.access_frecency_score),
|
||||
path: readCString(raw.path) ?? "",
|
||||
relativePath: readCString(raw.relative_path) ?? "",
|
||||
fileName: readCString(raw.file_name) ?? "",
|
||||
gitStatus: readCString(raw.git_status) ?? "",
|
||||
size: Number(raw.size),
|
||||
modified: Number(raw.modified),
|
||||
accessFrecencyScore: Number(raw.access_frecency_score),
|
||||
modificationFrecencyScore: Number(raw.modification_frecency_score),
|
||||
totalFrecencyScore: Number(raw.total_frecency_score),
|
||||
totalFrecencyScore: Number(raw.total_frecency_score),
|
||||
};
|
||||
}
|
||||
|
||||
function readScoreFromRaw(raw: FffScoreRaw): Score {
|
||||
return {
|
||||
total: raw.total,
|
||||
baseScore: raw.base_score,
|
||||
filenameBonus: raw.filename_bonus,
|
||||
specialFilenameBonus:raw.special_filename_bonus,
|
||||
frecencyBoost: raw.frecency_boost,
|
||||
distancePenalty: raw.distance_penalty,
|
||||
currentFilePenalty: raw.current_file_penalty,
|
||||
comboMatchBoost: raw.combo_match_boost,
|
||||
exactMatch: raw.exact_match !== 0,
|
||||
matchType: readCString(raw.match_type) ?? "",
|
||||
total: raw.total,
|
||||
baseScore: raw.base_score,
|
||||
filenameBonus: raw.filename_bonus,
|
||||
specialFilenameBonus: raw.special_filename_bonus,
|
||||
frecencyBoost: raw.frecency_boost,
|
||||
distancePenalty: raw.distance_penalty,
|
||||
currentFilePenalty: raw.current_file_penalty,
|
||||
comboMatchBoost: raw.combo_match_boost,
|
||||
exactMatch: raw.exact_match !== 0,
|
||||
matchType: readCString(raw.match_type) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -599,7 +627,7 @@ function readCStringArray(ptrArray: JsExternal, count: number): string[] {
|
||||
const elemPtr = ptrOffset(ptrArray, i * 8);
|
||||
const [charPtr] = restorePointer({
|
||||
retType: [DataType.External],
|
||||
paramsValue: wrapPointer([elemPtr]),
|
||||
paramsValue: [elemPtr],
|
||||
}) as unknown as [JsExternal];
|
||||
result.push(readCString(charPtr) ?? "");
|
||||
}
|
||||
@@ -619,20 +647,20 @@ function readGrepMatchFromRaw(raw: FffGrepMatchRaw): GrepMatch {
|
||||
}
|
||||
|
||||
const match: GrepMatch = {
|
||||
path: readCString(raw.path) ?? "",
|
||||
relativePath: readCString(raw.relative_path) ?? "",
|
||||
fileName: readCString(raw.file_name) ?? "",
|
||||
gitStatus: readCString(raw.git_status) ?? "",
|
||||
lineContent: readCString(raw.line_content) ?? "",
|
||||
size: Number(raw.size),
|
||||
modified: Number(raw.modified),
|
||||
totalFrecencyScore: Number(raw.total_frecency_score),
|
||||
accessFrecencyScore: Number(raw.access_frecency_score),
|
||||
path: readCString(raw.path) ?? "",
|
||||
relativePath: readCString(raw.relative_path) ?? "",
|
||||
fileName: readCString(raw.file_name) ?? "",
|
||||
gitStatus: readCString(raw.git_status) ?? "",
|
||||
lineContent: readCString(raw.line_content) ?? "",
|
||||
size: Number(raw.size),
|
||||
modified: Number(raw.modified),
|
||||
totalFrecencyScore: Number(raw.total_frecency_score),
|
||||
accessFrecencyScore: Number(raw.access_frecency_score),
|
||||
modificationFrecencyScore: Number(raw.modification_frecency_score),
|
||||
isBinary: raw.is_binary !== 0,
|
||||
lineNumber: Number(raw.line_number),
|
||||
col: raw.col,
|
||||
byteOffset: Number(raw.byte_offset),
|
||||
isBinary: raw.is_binary !== 0,
|
||||
lineNumber: Number(raw.line_number),
|
||||
col: raw.col,
|
||||
byteOffset: Number(raw.byte_offset),
|
||||
matchRanges,
|
||||
};
|
||||
|
||||
@@ -686,7 +714,10 @@ function parseGrepResult(rawPtr: JsExternal): Result<GrepResult> {
|
||||
const items: GrepMatch[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const rawMatch = callAccessor<FffGrepMatchRaw>(
|
||||
"fff_grep_result_get_match", handlePtr, i, FFF_GREP_MATCH_STRUCT,
|
||||
"fff_grep_result_get_match",
|
||||
handlePtr,
|
||||
i,
|
||||
FFF_GREP_MATCH_STRUCT,
|
||||
);
|
||||
items.push(readGrepMatchFromRaw(rawMatch));
|
||||
}
|
||||
@@ -770,12 +801,18 @@ function parseSearchResult(rawPtr: JsExternal): Result<SearchResult> {
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const rawItem = callAccessor<FffFileItemRaw>(
|
||||
"fff_search_result_get_item", handlePtr, i, FFF_FILE_ITEM_STRUCT,
|
||||
"fff_search_result_get_item",
|
||||
handlePtr,
|
||||
i,
|
||||
FFF_FILE_ITEM_STRUCT,
|
||||
);
|
||||
items.push(readFileItemFromRaw(rawItem));
|
||||
|
||||
const rawScore = callAccessor<FffScoreRaw>(
|
||||
"fff_search_result_get_score", handlePtr, i, FFF_SCORE_STRUCT,
|
||||
"fff_search_result_get_score",
|
||||
handlePtr,
|
||||
i,
|
||||
FFF_SCORE_STRUCT,
|
||||
);
|
||||
scores.push(readScoreFromRaw(rawScore));
|
||||
}
|
||||
@@ -789,7 +826,12 @@ function parseSearchResult(rawPtr: JsExternal): Result<SearchResult> {
|
||||
paramsValue: [handlePtr],
|
||||
});
|
||||
|
||||
const result: SearchResult = { items, scores, totalMatched: sr.total_matched, totalFiles: sr.total_files };
|
||||
const result: SearchResult = {
|
||||
items,
|
||||
scores,
|
||||
totalMatched: sr.total_matched,
|
||||
totalFiles: sr.total_files,
|
||||
};
|
||||
if (location) {
|
||||
result.location = location;
|
||||
}
|
||||
@@ -817,15 +859,24 @@ export function ffiSearch(
|
||||
retType: DataType.External,
|
||||
paramsType: [
|
||||
DataType.External, // handle
|
||||
DataType.String, // query
|
||||
DataType.String, // current_file
|
||||
DataType.U32, // max_threads
|
||||
DataType.U32, // page_index
|
||||
DataType.U32, // page_size
|
||||
DataType.I32, // combo_boost_multiplier
|
||||
DataType.U32, // min_combo_count
|
||||
DataType.String, // query
|
||||
DataType.String, // current_file
|
||||
DataType.U32, // max_threads
|
||||
DataType.U32, // page_index
|
||||
DataType.U32, // page_size
|
||||
DataType.I32, // combo_boost_multiplier
|
||||
DataType.U32, // min_combo_count
|
||||
],
|
||||
paramsValue: [
|
||||
handle,
|
||||
query,
|
||||
currentFile,
|
||||
maxThreads,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
comboBoostMultiplier,
|
||||
minComboCount,
|
||||
],
|
||||
paramsValue: [handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount],
|
||||
freeResultMemory: false,
|
||||
}) as JsExternal;
|
||||
|
||||
@@ -857,23 +908,31 @@ export function ffiLiveGrep(
|
||||
retType: DataType.External,
|
||||
paramsType: [
|
||||
DataType.External, // handle
|
||||
DataType.String, // query
|
||||
DataType.U8, // mode
|
||||
DataType.U64, // max_file_size
|
||||
DataType.U32, // max_matches_per_file
|
||||
DataType.Boolean, // smart_case
|
||||
DataType.U32, // file_offset
|
||||
DataType.U32, // page_limit
|
||||
DataType.U64, // time_budget_ms
|
||||
DataType.U32, // before_context
|
||||
DataType.U32, // after_context
|
||||
DataType.Boolean, // classify_definitions
|
||||
DataType.String, // query
|
||||
DataType.U8, // mode
|
||||
DataType.U64, // max_file_size
|
||||
DataType.U32, // max_matches_per_file
|
||||
DataType.Boolean, // smart_case
|
||||
DataType.U32, // file_offset
|
||||
DataType.U32, // page_limit
|
||||
DataType.U64, // time_budget_ms
|
||||
DataType.U32, // before_context
|
||||
DataType.U32, // after_context
|
||||
DataType.Boolean, // classify_definitions
|
||||
],
|
||||
paramsValue: [
|
||||
handle, query, grepModeToU8(mode),
|
||||
maxFileSize, maxMatchesPerFile, smartCase,
|
||||
fileOffset, pageLimit, timeBudgetMs,
|
||||
beforeContext, afterContext, classifyDefinitions,
|
||||
handle,
|
||||
query,
|
||||
grepModeToU8(mode),
|
||||
maxFileSize,
|
||||
maxMatchesPerFile,
|
||||
smartCase,
|
||||
fileOffset,
|
||||
pageLimit,
|
||||
timeBudgetMs,
|
||||
beforeContext,
|
||||
afterContext,
|
||||
classifyDefinitions,
|
||||
],
|
||||
freeResultMemory: false,
|
||||
}) as JsExternal;
|
||||
@@ -906,23 +965,31 @@ export function ffiMultiGrep(
|
||||
retType: DataType.External,
|
||||
paramsType: [
|
||||
DataType.External, // handle
|
||||
DataType.String, // patterns_joined
|
||||
DataType.String, // constraints
|
||||
DataType.U64, // max_file_size
|
||||
DataType.U32, // max_matches_per_file
|
||||
DataType.Boolean, // smart_case
|
||||
DataType.U32, // file_offset
|
||||
DataType.U32, // page_limit
|
||||
DataType.U64, // time_budget_ms
|
||||
DataType.U32, // before_context
|
||||
DataType.U32, // after_context
|
||||
DataType.Boolean, // classify_definitions
|
||||
DataType.String, // patterns_joined
|
||||
DataType.String, // constraints
|
||||
DataType.U64, // max_file_size
|
||||
DataType.U32, // max_matches_per_file
|
||||
DataType.Boolean, // smart_case
|
||||
DataType.U32, // file_offset
|
||||
DataType.U32, // page_limit
|
||||
DataType.U64, // time_budget_ms
|
||||
DataType.U32, // before_context
|
||||
DataType.U32, // after_context
|
||||
DataType.Boolean, // classify_definitions
|
||||
],
|
||||
paramsValue: [
|
||||
handle, patternsJoined, constraints,
|
||||
maxFileSize, maxMatchesPerFile, smartCase,
|
||||
fileOffset, pageLimit, timeBudgetMs,
|
||||
beforeContext, afterContext, classifyDefinitions,
|
||||
handle,
|
||||
patternsJoined,
|
||||
constraints,
|
||||
maxFileSize,
|
||||
maxMatchesPerFile,
|
||||
smartCase,
|
||||
fileOffset,
|
||||
pageLimit,
|
||||
timeBudgetMs,
|
||||
beforeContext,
|
||||
afterContext,
|
||||
classifyDefinitions,
|
||||
],
|
||||
freeResultMemory: false,
|
||||
}) as JsExternal;
|
||||
@@ -965,7 +1032,9 @@ interface FffScanProgressRaw {
|
||||
/**
|
||||
* Get scan progress.
|
||||
*/
|
||||
export function ffiGetScanProgress(handle: NativeHandle): Result<{ scannedFilesCount: number; isScanning: boolean }> {
|
||||
export function ffiGetScanProgress(
|
||||
handle: NativeHandle,
|
||||
): Result<{ scannedFilesCount: number; isScanning: boolean }> {
|
||||
loadLibrary();
|
||||
const res = readResultEnvelope("fff_get_scan_progress", [DataType.External], [handle]);
|
||||
if ("ok" in res) return res;
|
||||
@@ -1001,14 +1070,22 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<{ scannedFilesC
|
||||
* Wait for a tree scan to complete.
|
||||
*/
|
||||
export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
|
||||
return callBoolResult("fff_wait_for_scan", [DataType.External, DataType.U64], [handle, timeoutMs]);
|
||||
return callBoolResult(
|
||||
"fff_wait_for_scan",
|
||||
[DataType.External, DataType.U64],
|
||||
[handle, timeoutMs],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart index in new path.
|
||||
*/
|
||||
export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
|
||||
return callVoidResult("fff_restart_index", [DataType.External, DataType.String], [handle, newPath]);
|
||||
return callVoidResult(
|
||||
"fff_restart_index",
|
||||
[DataType.External, DataType.String],
|
||||
[handle, newPath],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1040,7 +1117,11 @@ export function ffiGetHistoricalQuery(
|
||||
handle: NativeHandle,
|
||||
offset: number,
|
||||
): Result<string | null> {
|
||||
return callStringResult("fff_get_historical_query", [DataType.External, DataType.U64], [handle, offset]);
|
||||
return callStringResult(
|
||||
"fff_get_historical_query",
|
||||
[DataType.External, DataType.U64],
|
||||
[handle, offset],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -445,4 +445,3 @@ export class FileFinder {
|
||||
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -360,4 +360,3 @@ export interface MultiGrepOptions {
|
||||
/** Number of context lines to include after each match (default: 0) */
|
||||
afterContext?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FileFinder, closeLibrary } from "../dist/src/index.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, "..", "..", "..");
|
||||
const normalizePath = (p) => p.replace(/\\/g, "/");
|
||||
|
||||
/** @type {import("../dist/src/finder.js").FileFinder | null} */
|
||||
let finder = null;
|
||||
@@ -167,6 +168,31 @@ describe("fff-node", { concurrency: 1 }, () => {
|
||||
assert.ok(r.ok, `regex grep failed: ${!r.ok ? r.error : ""}`);
|
||||
assert.ok(r.value.items.length > 0);
|
||||
});
|
||||
|
||||
it("decodes before/after context lines", () => {
|
||||
const r = finder.grep(
|
||||
"match.contextBefore = readCStringArray(raw.context_before, raw.context_before_count);",
|
||||
{
|
||||
mode: "plain",
|
||||
beforeContext: 1,
|
||||
afterContext: 1,
|
||||
maxMatchesPerFile: 5,
|
||||
},
|
||||
);
|
||||
assert.ok(r.ok, `grep with context failed: ${!r.ok ? r.error : ""}`);
|
||||
|
||||
const match = r.value.items.find(
|
||||
(m) => normalizePath(m.relativePath) === "packages/fff-node/src/ffi.ts",
|
||||
);
|
||||
assert.ok(
|
||||
match,
|
||||
`expected a match in packages/fff-node/src/ffi.ts, got: ${r.value.items
|
||||
.map((m) => normalizePath(m.relativePath))
|
||||
.join(", ")}`,
|
||||
);
|
||||
assert.deepEqual(match.contextBefore, [" if (raw.context_before_count > 0) {"]);
|
||||
assert.deepEqual(match.contextAfter, [" }"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiGrep", { concurrency: 1 }, () => {
|
||||
@@ -191,13 +217,11 @@ describe("fff-node", { concurrency: 1 }, () => {
|
||||
assert.ok(r.value > 0);
|
||||
});
|
||||
|
||||
// ── Scan ────────────────────────────────────────────────────────────
|
||||
|
||||
it("isScanning returns a boolean", () => {
|
||||
assert.equal(typeof finder.isScanning(), "boolean");
|
||||
});
|
||||
|
||||
// ── Health check ────────────────────────────────────────────────────
|
||||
|
||||
describe("healthCheck", { concurrency: 1 }, () => {
|
||||
it("reports initialized state with instance", () => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
local script_path = arg[0]
|
||||
local script_dir = script_path:match('(.*[/\\])') or './'
|
||||
local repo_root = script_dir .. '..'
|
||||
|
||||
-- Add the plugin's lua/ directory to the module search path
|
||||
package.path = repo_root .. '/lua/?.lua;' .. repo_root .. '/lua/?/init.lua;' .. package.path
|
||||
|
||||
local version = require('fff.utils.version')
|
||||
|
||||
local info, err = version.resolve(repo_root)
|
||||
if not info then
|
||||
io.stderr:write('Error: ' .. (err or 'unknown') .. '\n')
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
print('version=' .. info.version)
|
||||
print('npm_tag=' .. info.npm_tag)
|
||||
print('is_release=' .. tostring(info.is_release))
|
||||
|
||||
-- Write to GITHUB_OUTPUT when running in CI
|
||||
local github_output = os.getenv('GITHUB_OUTPUT')
|
||||
if github_output and github_output ~= '' then
|
||||
local f = io.open(github_output, 'a')
|
||||
if f then
|
||||
f:write('version=' .. info.version .. '\n')
|
||||
f:write('npm_tag=' .. info.npm_tag .. '\n')
|
||||
f:write('is_release=' .. tostring(info.is_release) .. '\n')
|
||||
f:close()
|
||||
end
|
||||
end
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Determines the release version based on git context.
|
||||
#
|
||||
# Tagged release (refs/tags/v*) → uses the tag version (e.g. v0.2.4 → 0.2.4)
|
||||
# Push to main → nightly prerelease (e.g. 0.2.5-nightly.abc1234)
|
||||
# Other (PR / dev branch) → dev prerelease (e.g. 0.2.5-dev.abc1234)
|
||||
#
|
||||
# For prerelease builds the patch version is bumped so that the resulting
|
||||
# semver is strictly greater than the current Cargo.toml version.
|
||||
# (In semver 0.2.4-nightly.x < 0.2.4, so we need 0.2.5-nightly.x > 0.2.4.)
|
||||
#
|
||||
# Outputs (appended to $GITHUB_OUTPUT when running in CI):
|
||||
# version – semver string
|
||||
# npm_tag – npm dist-tag (latest | nightly | dev)
|
||||
# is_release – "true" for tagged releases, "false" otherwise
|
||||
#
|
||||
# Can also be run locally for debugging:
|
||||
# GITHUB_REF=refs/tags/v0.3.0 ./scripts/determine-version.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Single source of truth: base version lives in fff-core
|
||||
base_version=$(grep '^version' "$REPO_ROOT/crates/fff-core/Cargo.toml" \
|
||||
| head -1 \
|
||||
| sed 's/version = "\(.*\)"/\1/')
|
||||
|
||||
short_sha=$(git -C "$REPO_ROOT" rev-parse --short HEAD)
|
||||
|
||||
# Bump the patch component: 0.2.4 → 0.2.5
|
||||
IFS='.' read -r major minor patch <<< "$base_version"
|
||||
next_patch_version="${major}.${minor}.$((patch + 1))"
|
||||
|
||||
ref="${GITHUB_REF:-}"
|
||||
|
||||
if [[ "$ref" == refs/tags/v* ]]; then
|
||||
# Tagged release – strip the leading "v"
|
||||
version="${ref#refs/tags/v}"
|
||||
npm_tag="latest"
|
||||
is_release="true"
|
||||
elif [[ "$ref" == "refs/heads/main" || "$ref" == "refs/heads/node" ]]; then
|
||||
version="${next_patch_version}-nightly.${short_sha}"
|
||||
npm_tag="nightly"
|
||||
is_release="false"
|
||||
else
|
||||
version="${next_patch_version}-dev.${short_sha}"
|
||||
npm_tag="dev"
|
||||
is_release="false"
|
||||
fi
|
||||
|
||||
echo "version=${version}"
|
||||
echo "npm_tag=${npm_tag}"
|
||||
echo "is_release=${is_release}"
|
||||
|
||||
# Write to GITHUB_OUTPUT when running in CI
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_release=${is_release}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -8,6 +8,8 @@ VERSION="${VERSION#v}"
|
||||
|
||||
TAG="v${VERSION}"
|
||||
|
||||
git pull
|
||||
|
||||
# Check for clean working tree
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Error: Working tree is not clean. Commit or stash changes first."
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---@diagnostic disable: undefined-field, missing-fields
|
||||
local fff_rust = require('fff.rust')
|
||||
|
||||
describe('clear_cache', function()
|
||||
local test_dir
|
||||
local tmp_frecency_path
|
||||
local tmp_history_path
|
||||
|
||||
before_each(function()
|
||||
test_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
if vim.fn.isdirectory(test_dir) ~= 1 then test_dir = vim.fn.getcwd() end
|
||||
|
||||
tmp_frecency_path = vim.fn.tempname() .. '_fff_test_frecency'
|
||||
tmp_history_path = vim.fn.tempname() .. '_fff_test_history'
|
||||
|
||||
vim.g.fff = {
|
||||
frecency = { enabled = true, db_path = tmp_frecency_path },
|
||||
history = { enabled = true, db_path = tmp_history_path },
|
||||
}
|
||||
package.loaded['fff.conf'] = nil
|
||||
package.loaded['fff.main'] = nil
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
pcall(fff_rust.stop_background_monitor)
|
||||
pcall(fff_rust.cleanup_file_picker)
|
||||
pcall(fff_rust.destroy_frecency_db)
|
||||
pcall(fff_rust.destroy_query_db)
|
||||
vim.fn.delete(tmp_frecency_path, 'rf')
|
||||
vim.fn.delete(tmp_history_path, 'rf')
|
||||
vim.g.fff = nil
|
||||
package.loaded['fff.conf'] = nil
|
||||
package.loaded['fff.main'] = nil
|
||||
end)
|
||||
|
||||
it('deletes on-disk database directories when clearing all', function()
|
||||
-- Initialize databases at temporary paths
|
||||
local ok = fff_rust.init_db(tmp_frecency_path, tmp_history_path, true)
|
||||
assert.is_true(ok)
|
||||
|
||||
-- LMDB creates the directory on init
|
||||
assert.are.equal(1, vim.fn.isdirectory(tmp_frecency_path), 'frecency db dir should exist after init')
|
||||
assert.are.equal(1, vim.fn.isdirectory(tmp_history_path), 'history db dir should exist after init')
|
||||
|
||||
local main = require('fff.main')
|
||||
local result = main.clear_cache('all')
|
||||
assert.is_true(result)
|
||||
|
||||
assert.are.equal(0, vim.fn.isdirectory(tmp_frecency_path), 'frecency db dir should be removed after clear')
|
||||
assert.are.equal(0, vim.fn.isdirectory(tmp_history_path), 'history db dir should be removed after clear')
|
||||
end)
|
||||
|
||||
it('deletes only frecency databases when scope is frecency', function()
|
||||
local ok = fff_rust.init_db(tmp_frecency_path, tmp_history_path, true)
|
||||
assert.is_true(ok)
|
||||
ok = fff_rust.init_file_picker(test_dir)
|
||||
assert.is_true(ok)
|
||||
fff_rust.wait_for_initial_scan(10000)
|
||||
|
||||
local main = require('fff.main')
|
||||
local result = main.clear_cache('frecency')
|
||||
assert.is_true(result)
|
||||
|
||||
assert.are.equal(0, vim.fn.isdirectory(tmp_frecency_path), 'frecency db dir should be removed')
|
||||
assert.are.equal(0, vim.fn.isdirectory(tmp_history_path), 'history db dir should be removed')
|
||||
|
||||
local progress = fff_rust.get_scan_progress()
|
||||
assert.is_not_nil(progress)
|
||||
assert.is_true(progress.scanned_files_count > 0, 'file picker should still have scanned files')
|
||||
end)
|
||||
|
||||
it('cleans file picker but keeps databases when scope is files', function()
|
||||
local ok = fff_rust.init_db(tmp_frecency_path, tmp_history_path, true)
|
||||
assert.is_true(ok)
|
||||
ok = fff_rust.init_file_picker(test_dir)
|
||||
assert.is_true(ok)
|
||||
fff_rust.wait_for_initial_scan(10000)
|
||||
|
||||
local main = require('fff.main')
|
||||
local result = main.clear_cache('files')
|
||||
assert.is_true(result)
|
||||
|
||||
assert.are.equal(1, vim.fn.isdirectory(tmp_frecency_path), 'frecency db dir should still exist')
|
||||
assert.are.equal(1, vim.fn.isdirectory(tmp_history_path), 'history db dir should still exist')
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,234 @@
|
||||
---@diagnostic disable: undefined-field, need-check-nil, param-type-mismatch
|
||||
local version = require('fff.utils.version')
|
||||
|
||||
describe('fff.utils.version', function()
|
||||
local repo_root
|
||||
|
||||
before_each(function()
|
||||
repo_root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
if vim.fn.isdirectory(repo_root) ~= 1 then repo_root = vim.fn.getcwd() end
|
||||
end)
|
||||
|
||||
describe('read_base_version', function()
|
||||
it('should read version from Cargo.toml', function()
|
||||
local ver = version.read_base_version(repo_root)
|
||||
assert.is_not_nil(ver)
|
||||
assert.is_string(ver)
|
||||
assert.is_truthy(ver:match('^%d+%.%d+%.%d+'), 'expected semver, got: ' .. ver)
|
||||
end)
|
||||
|
||||
it(
|
||||
'should return nil for missing directory',
|
||||
function() assert.is_nil(version.read_base_version('/nonexistent_path_12345')) end
|
||||
)
|
||||
|
||||
it('should parse version from a temp Cargo.toml', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp .. '/crates/fff-core', 'p')
|
||||
|
||||
local f = io.open(tmp .. '/crates/fff-core/Cargo.toml', 'w')
|
||||
f:write('[package]\nname = "test"\nversion = "1.2.3"\n')
|
||||
f:close()
|
||||
|
||||
assert.are.equal('1.2.3', version.read_base_version(tmp))
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('current_release_tag', function()
|
||||
it('should return a string or nil for the real repo', function()
|
||||
local tag = version.current_release_tag(repo_root)
|
||||
-- On CI the commit has a tag; locally it might not
|
||||
if tag then
|
||||
assert.is_string(tag)
|
||||
else
|
||||
assert.is_nil(tag)
|
||||
end
|
||||
end)
|
||||
|
||||
it('should return nil for a repo with no tags', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp, 'p')
|
||||
|
||||
vim.fn.system({ 'git', 'init', '-q', tmp })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.email', 'test@test.com' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.name', 'Test' })
|
||||
|
||||
local f = io.open(tmp .. '/file.txt', 'w')
|
||||
f:write('hello')
|
||||
f:close()
|
||||
|
||||
vim.fn.system({ 'git', '-C', tmp, 'add', '.' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'commit', '-q', '-m', 'init' })
|
||||
|
||||
assert.is_nil(version.current_release_tag(tmp))
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
|
||||
it('should prefer v* tags over nightly/dev/legacy', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp, 'p')
|
||||
|
||||
vim.fn.system({ 'git', 'init', '-q', tmp })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.email', 'test@test.com' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.name', 'Test' })
|
||||
|
||||
local f = io.open(tmp .. '/file.txt', 'w')
|
||||
f:write('hello')
|
||||
f:close()
|
||||
|
||||
vim.fn.system({ 'git', '-C', tmp, 'add', '.' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'commit', '-q', '-m', 'init' })
|
||||
|
||||
-- Add multiple tags on the same commit
|
||||
vim.fn.system({ 'git', '-C', tmp, 'tag', '0.4.0-dev.abc1234' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'tag', '0.4.0-nightly.abc1234' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'tag', 'v0.4.0' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'tag', 'deadbeef' })
|
||||
|
||||
assert.are.equal('v0.4.0', version.current_release_tag(tmp))
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
|
||||
it('should prefer nightly over dev', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp, 'p')
|
||||
|
||||
vim.fn.system({ 'git', 'init', '-q', tmp })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.email', 'test@test.com' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.name', 'Test' })
|
||||
|
||||
local f = io.open(tmp .. '/file.txt', 'w')
|
||||
f:write('hello')
|
||||
f:close()
|
||||
|
||||
vim.fn.system({ 'git', '-C', tmp, 'add', '.' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'commit', '-q', '-m', 'init' })
|
||||
|
||||
vim.fn.system({ 'git', '-C', tmp, 'tag', '0.4.0-dev.abc1234' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'tag', '0.4.0-nightly.abc1234' })
|
||||
|
||||
assert.are.equal('0.4.0-nightly.abc1234', version.current_release_tag(tmp))
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('resolve', function()
|
||||
it('should resolve a version from the real repo', function()
|
||||
local info, err = version.resolve(repo_root)
|
||||
assert.is_nil(err)
|
||||
assert.is_not_nil(info)
|
||||
assert.is_string(info.version)
|
||||
assert.is_string(info.release_tag)
|
||||
assert.is_string(info.npm_tag)
|
||||
assert.is_not_nil(info.is_release)
|
||||
end)
|
||||
|
||||
it('should produce a version higher than Cargo.toml base', function()
|
||||
local info = version.resolve(repo_root)
|
||||
local base = version.read_base_version(repo_root)
|
||||
assert.is_not_nil(info)
|
||||
assert.is_not_nil(base)
|
||||
-- For prereleases, patch is bumped: 0.4.0 → 0.4.1-nightly.{sha}
|
||||
if not info.is_release then
|
||||
local base_major, base_minor, base_patch = base:match('^(%d+)%.(%d+)%.(%d+)')
|
||||
local expected_patch = tostring(tonumber(base_patch) + 1)
|
||||
assert.is_truthy(
|
||||
info.version:find(base_major .. '%.' .. base_minor .. '%.' .. expected_patch),
|
||||
'version "' .. info.version .. '" should have bumped patch from base "' .. base .. '"'
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
it('should return dev on a non-main branch', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp .. '/crates/fff-core', 'p')
|
||||
|
||||
vim.fn.system({ 'git', 'init', '-q', tmp })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.email', 'test@test.com' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.name', 'Test' })
|
||||
|
||||
local f = io.open(tmp .. '/crates/fff-core/Cargo.toml', 'w')
|
||||
f:write('[package]\nname = "test"\nversion = "1.0.0"\nedition = "2024"\n')
|
||||
f:close()
|
||||
|
||||
vim.fn.system({ 'git', '-C', tmp, 'add', '.' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'commit', '-q', '-m', 'init' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'checkout', '-b', 'feature-x' })
|
||||
|
||||
local info = version.resolve(tmp)
|
||||
assert.is_not_nil(info)
|
||||
assert.are.equal('dev', info.npm_tag)
|
||||
assert.is_false(info.is_release)
|
||||
assert.is_truthy(info.version:find('-dev%.'), 'expected dev prerelease, got: ' .. info.version)
|
||||
assert.is_truthy(info.version:find('^1%.0%.1%-'), 'expected bumped patch 1.0.1, got: ' .. info.version)
|
||||
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
|
||||
it('should return nightly on main branch', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp .. '/crates/fff-core', 'p')
|
||||
|
||||
vim.fn.system({ 'git', 'init', '-q', '-b', 'main', tmp })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.email', 'test@test.com' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.name', 'Test' })
|
||||
|
||||
local f = io.open(tmp .. '/crates/fff-core/Cargo.toml', 'w')
|
||||
f:write('[package]\nname = "test"\nversion = "2.0.0"\nedition = "2024"\n')
|
||||
f:close()
|
||||
|
||||
vim.fn.system({ 'git', '-C', tmp, 'add', '.' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'commit', '-q', '-m', 'init' })
|
||||
|
||||
local info = version.resolve(tmp)
|
||||
assert.is_not_nil(info)
|
||||
assert.are.equal('nightly', info.npm_tag)
|
||||
assert.is_false(info.is_release)
|
||||
assert.is_truthy(info.version:find('2%.0%.1%-nightly%.'), 'expected 2.0.1-nightly, got: ' .. info.version)
|
||||
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
|
||||
it('should return stable release for v* tagged commit', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp .. '/crates/fff-core', 'p')
|
||||
|
||||
vim.fn.system({ 'git', 'init', '-q', tmp })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.email', 'test@test.com' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'config', 'user.name', 'Test' })
|
||||
|
||||
local f = io.open(tmp .. '/crates/fff-core/Cargo.toml', 'w')
|
||||
f:write('[package]\nname = "test"\nversion = "3.0.0"\nedition = "2024"\n')
|
||||
f:close()
|
||||
|
||||
vim.fn.system({ 'git', '-C', tmp, 'add', '.' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'commit', '-q', '-m', 'release' })
|
||||
vim.fn.system({ 'git', '-C', tmp, 'tag', 'v3.0.0' })
|
||||
|
||||
local info = version.resolve(tmp)
|
||||
assert.is_not_nil(info)
|
||||
assert.are.equal('v3.0.0', info.release_tag)
|
||||
assert.are.equal('3.0.0', info.version)
|
||||
assert.are.equal('latest', info.npm_tag)
|
||||
assert.is_true(info.is_release)
|
||||
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
|
||||
it('should fail for a non-git directory', function()
|
||||
local tmp = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp .. '/crates/fff-core', 'p')
|
||||
|
||||
local f = io.open(tmp .. '/crates/fff-core/Cargo.toml', 'w')
|
||||
f:write('[package]\nname = "test"\nversion = "1.0.0"\n')
|
||||
f:close()
|
||||
|
||||
local info, err = version.resolve(tmp)
|
||||
assert.is_nil(info)
|
||||
assert.is_not_nil(err)
|
||||
|
||||
vim.fn.delete(tmp, 'rf')
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
Reference in New Issue
Block a user